-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Add project file picker #3985
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add project file picker #3985
Changes from 1 commit
72f5b0e
5db7966
7d64e8d
d0730ad
46a52f4
7fe3135
0765a5f
e17a269
276a18d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -97,6 +97,7 @@ import { | |
| buildRootGroups, | ||
| buildThreadActionItems, | ||
| type CommandPaletteActionItem, | ||
| type CommandPaletteOpenIntent, | ||
| type CommandPaletteSubmenuItem, | ||
| type CommandPaletteView, | ||
| filterBrowseEntries, | ||
|
|
@@ -105,11 +106,13 @@ import { | |
| getCommandPaletteMode, | ||
| ITEM_ICON_CLASS, | ||
| RECENT_THREAD_LIMIT, | ||
| reduceCommandPaletteUiState, | ||
| } from "./CommandPalette.logic"; | ||
| import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; | ||
| import { CommandPaletteResults } from "./CommandPaletteResults"; | ||
| import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; | ||
| import { ProjectFavicon } from "./ProjectFavicon"; | ||
| import { ProjectFilePicker } from "./files/ProjectFilePicker"; | ||
| import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; | ||
| import { primaryServerKeybindingsAtom } from "../state/server"; | ||
| import { resolveShortcutCommand } from "../keybindings"; | ||
|
|
@@ -334,47 +337,15 @@ function errorMessage(error: unknown): string { | |
| return "An error occurred."; | ||
| } | ||
|
|
||
| interface CommandPaletteOpenIntent { | ||
| readonly kind: "add-project"; | ||
| } | ||
|
|
||
| interface CommandPaletteUiState { | ||
| readonly open: boolean; | ||
| readonly openIntent: CommandPaletteOpenIntent | null; | ||
| } | ||
|
|
||
| type CommandPaletteUiAction = | ||
| | { readonly _tag: "SetOpen"; readonly open: boolean } | ||
| | { readonly _tag: "Toggle" } | ||
| | { readonly _tag: "OpenAddProject" } | ||
| | { readonly _tag: "ClearOpenIntent" }; | ||
|
|
||
| function reduceCommandPaletteUiState( | ||
| state: CommandPaletteUiState, | ||
| action: CommandPaletteUiAction, | ||
| ): CommandPaletteUiState { | ||
| switch (action._tag) { | ||
| case "SetOpen": | ||
| return { | ||
| open: action.open, | ||
| openIntent: action.open ? state.openIntent : null, | ||
| }; | ||
| case "Toggle": | ||
| return { open: !state.open, openIntent: null }; | ||
| case "OpenAddProject": | ||
| return { open: true, openIntent: { kind: "add-project" } }; | ||
| case "ClearOpenIntent": | ||
| return state.openIntent ? { ...state, openIntent: null } : state; | ||
| } | ||
| } | ||
|
|
||
| export function CommandPalette({ children }: { children: ReactNode }) { | ||
| const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { | ||
| open: false, | ||
| mode: "command", | ||
| openIntent: null, | ||
| }); | ||
| const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); | ||
| const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []); | ||
| const toggleCommand = useCallback(() => dispatch({ _tag: "ToggleCommand" }), []); | ||
| const toggleFiles = useCallback(() => dispatch({ _tag: "ToggleFiles" }), []); | ||
| const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); | ||
| const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); | ||
| const keybindings = useAtomValue(primaryServerKeybindingsAtom); | ||
|
|
@@ -399,23 +370,23 @@ export function CommandPalette({ children }: { children: ReactNode }) { | |
| terminalOpen, | ||
| }, | ||
| }); | ||
| if (command !== "commandPalette.toggle") { | ||
| return; | ||
| } | ||
| if (command !== "commandPalette.toggle" && command !== "filePicker.toggle") return; | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| toggleOpen(); | ||
| if (command === "filePicker.toggle") toggleFiles(); | ||
| else toggleCommand(); | ||
| }; | ||
| window.addEventListener("keydown", onKeyDown); | ||
| return () => window.removeEventListener("keydown", onKeyDown); | ||
| }, [keybindings, terminalOpen, toggleOpen]); | ||
| }, [keybindings, terminalOpen, toggleCommand, toggleFiles]); | ||
|
|
||
| return ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Critical The 🤖 Copy this AI Prompt to have your agent fix this: |
||
| <OpenAddProjectCommandPaletteProvider openAddProject={openAddProject}> | ||
| <ComposerHandleContext value={composerHandleRef}> | ||
| <CommandDialog open={state.open} onOpenChange={setOpen}> | ||
| {children} | ||
| <CommandPaletteDialog | ||
| mode={state.mode} | ||
| open={state.open} | ||
| openIntent={state.openIntent} | ||
| setOpen={setOpen} | ||
|
|
@@ -429,6 +400,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { | |
|
|
||
| function CommandPaletteDialog(props: { | ||
| readonly open: boolean; | ||
| readonly mode: "command" | "files"; | ||
| readonly openIntent: CommandPaletteOpenIntent | null; | ||
| readonly setOpen: (open: boolean) => void; | ||
| readonly clearOpenIntent: () => void; | ||
|
|
@@ -437,6 +409,10 @@ function CommandPaletteDialog(props: { | |
| return null; | ||
| } | ||
|
|
||
| if (props.mode === "files") { | ||
| return <ProjectFilePicker setOpen={props.setOpen} />; | ||
| } | ||
|
|
||
| return ( | ||
| <OpenCommandPaletteDialog | ||
| openIntent={props.openIntent} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { assert, describe, it } from "vite-plus/test"; | ||
|
|
||
| import { getProjectFilePickerMatches } from "./ProjectFilePicker.logic"; | ||
|
|
||
| function pathsForQuery(entries: Parameters<typeof getProjectFilePickerMatches>[0], query: string) { | ||
| return getProjectFilePickerMatches(entries, query).map(({ name, path }) => ({ name, path })); | ||
| } | ||
|
|
||
| const entries = [ | ||
| { kind: "directory", path: "apps/web/src" }, | ||
| { kind: "file", path: "apps/web/src/index.ts" }, | ||
| { kind: "file", path: "packages/shared/src/index.ts" }, | ||
| { kind: "file", path: "README.md" }, | ||
| { kind: "file", path: ".gitignore" }, | ||
| ] as const; | ||
|
|
||
| describe("getProjectFilePickerMatches", () => { | ||
| it("returns files only and preserves index order for an empty query", () => { | ||
| assert.deepEqual(pathsForQuery(entries, ""), [ | ||
| { name: "index.ts", path: "apps/web/src/index.ts" }, | ||
| { name: "index.ts", path: "packages/shared/src/index.ts" }, | ||
| { name: "README.md", path: "README.md" }, | ||
| { name: ".gitignore", path: ".gitignore" }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("matches against both file names and paths", () => { | ||
| assert.deepEqual(pathsForQuery(entries, "shared"), [ | ||
| { name: "index.ts", path: "packages/shared/src/index.ts" }, | ||
| ]); | ||
| assert.deepEqual(pathsForQuery(entries, "read"), [{ name: "README.md", path: "README.md" }]); | ||
| }); | ||
|
|
||
| it("supports space-separated path tokens and a result limit", () => { | ||
| assert.deepEqual( | ||
| getProjectFilePickerMatches(entries, "src index", 1).map(({ name, path }) => ({ | ||
| name, | ||
| path, | ||
| })), | ||
| [{ name: "index.ts", path: "apps/web/src/index.ts" }], | ||
| ); | ||
| }); | ||
|
|
||
| it("matches ordered characters while allowing skipped characters", () => { | ||
| const fuzzyEntries = [ | ||
| { kind: "file", path: "src/TestFlags.tsx" }, | ||
| { kind: "file", path: "src/SubtestFlow.tsx" }, | ||
| { kind: "file", path: "src/useSubtestFlags.ts" }, | ||
| { | ||
| kind: "file", | ||
| path: "src/useSubtestFlags/useTabActivity.ts", | ||
| }, | ||
| { kind: "file", path: "src/TestResults.tsx" }, | ||
| ] as const; | ||
|
|
||
| assert.deepEqual( | ||
| pathsForQuery(fuzzyEntries, "testf").map(({ name }) => name), | ||
| ["TestFlags.tsx", "SubtestFlow.tsx", "useSubtestFlags.ts", "useTabActivity.ts"], | ||
| ); | ||
| assert.deepEqual(getProjectFilePickerMatches(fuzzyEntries, "tsfl")[0], { | ||
| name: "TestFlags.tsx", | ||
| nameMatchIndices: [0, 2, 4, 5], | ||
| path: "src/TestFlags.tsx", | ||
| pathMatchIndices: [4, 6, 8, 9], | ||
| }); | ||
| }); | ||
|
|
||
| it("uses the first ordered subsequence for highlighting", () => { | ||
| assert.deepEqual( | ||
| getProjectFilePickerMatches([{ kind: "file", path: "aabba" }], "aba")[0]?.nameMatchIndices, | ||
| [0, 2, 4], | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import type { ProjectEntry } from "@t3tools/contracts"; | ||
|
|
||
| export const PROJECT_FILE_PICKER_RESULT_LIMIT = 200; | ||
|
|
||
| export interface ProjectFilePickerMatch { | ||
| readonly name: string; | ||
| readonly nameMatchIndices: ReadonlyArray<number>; | ||
| readonly path: string; | ||
| readonly pathMatchIndices: ReadonlyArray<number>; | ||
| } | ||
|
|
||
| function fileName(path: string): string { | ||
| return path.slice(path.lastIndexOf("/") + 1); | ||
| } | ||
|
|
||
| function findMatchIndices(value: string, query: string): number[] | null { | ||
| if (!query) return []; | ||
|
|
||
| const normalizedValue = value.toLowerCase(); | ||
| const indices: number[] = []; | ||
| let queryIndex = 0; | ||
|
|
||
| for (let valueIndex = 0; valueIndex < normalizedValue.length; valueIndex += 1) { | ||
| if (normalizedValue[valueIndex] !== query[queryIndex]) continue; | ||
| indices.push(valueIndex); | ||
| queryIndex += 1; | ||
| if (queryIndex === query.length) return indices; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| export function getProjectFilePickerMatches( | ||
| entries: ReadonlyArray<ProjectEntry>, | ||
| rawQuery: string, | ||
| limit = PROJECT_FILE_PICKER_RESULT_LIMIT, | ||
| ): ProjectFilePickerMatch[] { | ||
| if (limit <= 0) return []; | ||
|
|
||
| const query = rawQuery.toLowerCase().replaceAll(/\s/g, ""); | ||
| const matches: ProjectFilePickerMatch[] = []; | ||
|
|
||
| for (const entry of entries) { | ||
|
jakeleventhal marked this conversation as resolved.
|
||
| if (entry.kind !== "file") continue; | ||
|
|
||
| const name = fileName(entry.path); | ||
| const nameMatchIndices = findMatchIndices(name, query); | ||
| const pathMatchIndices = findMatchIndices(entry.path, query); | ||
| if (nameMatchIndices === null && pathMatchIndices === null) continue; | ||
|
jakeleventhal marked this conversation as resolved.
Outdated
|
||
|
|
||
| matches.push({ | ||
| name, | ||
| nameMatchIndices: nameMatchIndices ?? [], | ||
| path: entry.path, | ||
| pathMatchIndices: pathMatchIndices ?? [], | ||
| }); | ||
| if (matches.length >= limit) break; | ||
| } | ||
|
|
||
| return matches; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.