Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p");
assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,40 @@ import type { Thread } from "../types";
import {
buildThreadActionItems,
filterCommandPaletteGroups,
reduceCommandPaletteUiState,
type CommandPaletteGroup,
} from "./CommandPalette.logic";

const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
const PROJECT_ID = ProjectId.make("project-1");

describe("reduceCommandPaletteUiState", () => {
const closedState = { open: false, mode: "command", openIntent: null } as const;

it("opens, switches, and closes command and file-picker modes", () => {
const filesOpen = reduceCommandPaletteUiState(closedState, { _tag: "ToggleFiles" });
expect(filesOpen).toEqual({ open: true, mode: "files", openIntent: null });

const commandOpen = reduceCommandPaletteUiState(filesOpen, { _tag: "ToggleCommand" });
expect(commandOpen).toEqual({ open: true, mode: "command", openIntent: null });

expect(reduceCommandPaletteUiState(commandOpen, { _tag: "ToggleCommand" })).toEqual({
open: false,
mode: "command",
openIntent: null,
});
});

it("routes add-project requests back to command mode", () => {
const filesOpen = reduceCommandPaletteUiState(closedState, { _tag: "ToggleFiles" });
expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenAddProject" })).toEqual({
open: true,
mode: "command",
openIntent: { kind: "add-project" },
});
});
});

function makeThread(overrides: Partial<Thread> = {}): Thread {
return {
id: ThreadId.make("thread-1"),
Expand Down
45 changes: 44 additions & 1 deletion apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,55 @@ export const RECENT_THREAD_LIMIT = 12;
export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";
export const ADDON_ICON_CLASS = "size-4";

export interface CommandPaletteOpenIntent {
readonly kind: "add-project";
}

export interface CommandPaletteUiState {
readonly open: boolean;
readonly mode: "command" | "files";
readonly openIntent: CommandPaletteOpenIntent | null;
}

export type CommandPaletteUiAction =
| { readonly _tag: "SetOpen"; readonly open: boolean }
| { readonly _tag: "ToggleCommand" }
| { readonly _tag: "ToggleFiles" }
| { readonly _tag: "OpenAddProject" }
| { readonly _tag: "ClearOpenIntent" };

export function reduceCommandPaletteUiState(
state: CommandPaletteUiState,
action: CommandPaletteUiAction,
): CommandPaletteUiState {
switch (action._tag) {
case "SetOpen":
return {
...state,
open: action.open,
openIntent: action.open ? state.openIntent : null,
Comment thread
jakeleventhal marked this conversation as resolved.
};
case "ToggleCommand":
return state.open && state.mode === "command"
? { ...state, open: false, openIntent: null }
: { open: true, mode: "command", openIntent: null };
case "ToggleFiles":
return state.open && state.mode === "files"
? { ...state, open: false, openIntent: null }
: { open: true, mode: "files", openIntent: null };
case "OpenAddProject":
return { open: true, mode: "command", openIntent: { kind: "add-project" } };
case "ClearOpenIntent":
return state.openIntent ? { ...state, openIntent: null } : state;
}
}

export interface CommandPaletteItem {
readonly kind: "action" | "submenu";
readonly value: string;
readonly searchTerms: ReadonlyArray<string>;
readonly title: ReactNode;
readonly description?: string;
readonly description?: ReactNode;
readonly timestamp?: string;
readonly icon: ReactNode;
readonly disabled?: boolean;
Expand Down
56 changes: 16 additions & 40 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import {
buildRootGroups,
buildThreadActionItems,
type CommandPaletteActionItem,
type CommandPaletteOpenIntent,
type CommandPaletteSubmenuItem,
type CommandPaletteView,
filterBrowseEntries,
Expand All @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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 (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical components/CommandPalette.tsx:382

The CommandPalette component no longer renders OpenAddProjectCommandPaletteProvider, but its children (which include AppSidebarLayout and route Outlet) still consume the OpenAddProjectCommandPalette context via useOpenAddProjectCommandPalette(). That hook throws when the context value is absent, so authenticated app-shell routes crash during render instead of displaying the UI. Consider re-wrapping children in OpenAddProjectCommandPaletteProvider (passing openAddProject) before rendering CommandDialog.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/CommandPalette.tsx around line 382:

The `CommandPalette` component no longer renders `OpenAddProjectCommandPaletteProvider`, but its `children` (which include `AppSidebarLayout` and route `Outlet`) still consume the `OpenAddProjectCommandPalette` context via `useOpenAddProjectCommandPalette()`. That hook throws when the context value is absent, so authenticated app-shell routes crash during render instead of displaying the UI. Consider re-wrapping `children` in `OpenAddProjectCommandPaletteProvider` (passing `openAddProject`) before rendering `CommandDialog`.

<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}
Expand All @@ -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;
Expand All @@ -437,6 +409,10 @@ function CommandPaletteDialog(props: {
return null;
}

if (props.mode === "files") {
return <ProjectFilePicker setOpen={props.setOpen} />;
}

return (
<OpenCommandPaletteDialog
openIntent={props.openIntent}
Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/components/files/ProjectFilePicker.logic.test.ts
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],
);
});
});
61 changes: 61 additions & 0 deletions apps/web/src/components/files/ProjectFilePicker.logic.ts
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) {
Comment thread
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;
Comment thread
jakeleventhal marked this conversation as resolved.
Outdated

matches.push({
name,
nameMatchIndices: nameMatchIndices ?? [],
path: entry.path,
pathMatchIndices: pathMatchIndices ?? [],
});
if (matches.length >= limit) break;
}

return matches;
}
Loading
Loading