Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
133 changes: 133 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import {
filterPinnedBrowseEntries,
filterCommandPaletteGroups,
reduceCommandPaletteUiState,
createCommandPaletteSearchState,
reduceCommandPaletteSearchState,
type CommandPaletteActionItem,
type CommandPaletteGroup,
type CommandPaletteUiState,
} from "./CommandPalette.logic";

describe("linked pull request thread navigation", () => {
Expand Down Expand Up @@ -851,3 +854,133 @@ describe("filterCommandPaletteGroups", () => {
]);
});
});

describe("command palette search return", () => {
const projectPicker = { addonIcon: null, groups: [] };

it("restores distinct queries through multiple nested pickers", () => {
let state = createCommandPaletteSearchState(">new thread");
state = reduceCommandPaletteSearchState(state, { type: "push", view: projectPicker });
expect(state.query).toBe("");
state = reduceCommandPaletteSearchState(state, { type: "query", query: "remote project" });
state = reduceCommandPaletteSearchState(state, {
type: "push",
view: { ...projectPicker, initialQuery: "~/" },
});
state = reduceCommandPaletteSearchState(state, { type: "query", query: "~/work" });
state = reduceCommandPaletteSearchState(state, { type: "back" });
expect(state.query).toBe("remote project");
expect(state.views).toHaveLength(1);
state = reduceCommandPaletteSearchState(state, { type: "back" });
expect(state).toEqual(createCommandPaletteSearchState(">new thread"));
expect(reduceCommandPaletteSearchState(state, { type: "back" })).toBe(state);
});

it("clearing a child prefix before Back does not erase the parent input", () => {
let state = createCommandPaletteSearchState(">add project");
state = reduceCommandPaletteSearchState(state, {
type: "push",
view: { ...projectPicker, initialQuery: "~/" },
});
state = reduceCommandPaletteSearchState(state, { type: "query", query: "" });
state = reduceCommandPaletteSearchState(state, { type: "back" });
expect(state.query).toBe(">add project");
state = reduceCommandPaletteSearchState(state, { type: "query", query: "" });
expect(state).toEqual(createCommandPaletteSearchState());
});

it("new direct-open and fresh-open searches cannot return to old input", () => {
const nested = reduceCommandPaletteSearchState(createCommandPaletteSearchState("old search"), {
type: "push",
view: projectPicker,
});
const newSearch = reduceCommandPaletteSearchState(nested, {
type: "reset",
query: "linked thread",
});
expect(reduceCommandPaletteSearchState(newSearch, { type: "back" })).toEqual(
createCommandPaletteSearchState("linked thread"),
);
const directPicker = reduceCommandPaletteSearchState(
reduceCommandPaletteSearchState(nested, { type: "reset" }),
{ type: "push", view: projectPicker },
);
expect(reduceCommandPaletteSearchState(directPicker, { type: "back" })).toEqual(
createCommandPaletteSearchState(),
);
expect(createCommandPaletteSearchState()).toEqual({ query: "", views: [] });
});
});

describe("palette-origin search overlay return", () => {
const root: CommandPaletteUiState = { open: true, mode: "command", openIntent: null };
const context = {
query: ">find file",
linkedThreadSearch: null,
};

it.each(["files", "content"] as const)(
"returns from %s without importing the child query",
(mode) => {
const child = reduceCommandPaletteUiState(root, {
_tag: "OpenChildSearch",
mode,
context,
});
const returned = reduceCommandPaletteUiState(child, { _tag: "BackToCommand" });
expect(returned.mode).toBe("command");
expect(returned.restoredSearch).toEqual(context);
expect(returned.parentSearch).toBeUndefined();
expect(createCommandPaletteSearchState(returned.restoredSearch?.query)).toEqual({
query: ">find file",
views: [],
});
},
);

it("retains linked-thread search data without carrying the old palette's views", () => {
const linkedContext = {
query: "https://github.com/acme/web/pull/7",
linkedThreadSearch: {
kind: "search" as const,
query: "https://github.com/acme/web/pull/7",
linkedThreads: {
environmentId: EnvironmentId.make("remote"),
threads: [],
},
},
};
const child = reduceCommandPaletteUiState(root, {
_tag: "OpenChildSearch",
mode: "content",
context: linkedContext,
});
const returned = reduceCommandPaletteUiState(child, { _tag: "BackToCommand" });
expect(returned.restoredSearch).toEqual(linkedContext);
expect(createCommandPaletteSearchState(returned.restoredSearch?.query).views).toEqual([]);
});

it("closing after a selection discards the return context and cannot reopen the parent", () => {
const child = reduceCommandPaletteUiState(root, {
_tag: "OpenChildSearch",
mode: "files",
context,
});
const closed = reduceCommandPaletteUiState(child, { _tag: "SetOpen", open: false });
expect(reduceCommandPaletteUiState(closed, { _tag: "BackToCommand" })).toBe(closed);
expect(reduceCommandPaletteUiState(closed, { _tag: "SetOpen", open: true })).toEqual(root);
});

it("a fresh shortcut or direct intent discards a previous return context", () => {
const child = reduceCommandPaletteUiState(root, {
_tag: "OpenChildSearch",
mode: "files",
context,
});
const fresh = reduceCommandPaletteUiState(child, { _tag: "ToggleMode", mode: "content" });
expect(reduceCommandPaletteUiState(fresh, { _tag: "BackToCommand" })).toEqual(root);
const direct = reduceCommandPaletteUiState(child, { _tag: "OpenChangeTheme" });
expect(direct.parentSearch).toBeUndefined();
expect(direct.restoredSearch).toBeUndefined();
});
});
69 changes: 66 additions & 3 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface CommandPaletteUiState {
readonly open: boolean;
readonly mode: SearchOverlayMode;
readonly openIntent: CommandPaletteOpenIntent | null;
readonly parentSearch?: CommandPaletteReturnContext;
readonly restoredSearch?: CommandPaletteReturnContext;
}

export type CommandPaletteUiAction =
Expand All @@ -85,7 +87,13 @@ export type CommandPaletteUiAction =
| { readonly _tag: "OpenAddProject" }
| { readonly _tag: "OpenNewThreadIn" }
| { readonly _tag: "OpenChangeTheme" }
| { readonly _tag: "ClearOpenIntent" };
| { readonly _tag: "ClearOpenIntent" }
| {
readonly _tag: "OpenChildSearch";
readonly mode: "files" | "content";
readonly context: CommandPaletteReturnContext;
}
| { readonly _tag: "BackToCommand" };

export function reduceCommandPaletteUiState(
state: CommandPaletteUiState,
Expand All @@ -95,11 +103,22 @@ export function reduceCommandPaletteUiState(
case "SetOpen":
return action.open
? { open: true, mode: "command", openIntent: state.openIntent }
: { ...state, open: false, openIntent: null };
: { open: false, mode: state.mode, openIntent: null };
case "ToggleMode":
return state.open && state.mode === action.mode
? { ...state, open: false, openIntent: null }
? { open: false, mode: state.mode, openIntent: null }
: { open: true, mode: action.mode, openIntent: null };
case "OpenChildSearch":
return { open: true, mode: action.mode, openIntent: null, parentSearch: action.context };
case "BackToCommand":
return state.open && state.mode !== "command"
? {
open: true,
mode: "command",
openIntent: null,
...(state.parentSearch ? { restoredSearch: state.parentSearch } : {}),
}
: state;
case "OpenSearch":
return {
open: true,
Expand Down Expand Up @@ -172,6 +191,50 @@ export interface CommandPaletteView {
readonly initialQuery?: string;
}

// File/content search actions only appear at the root. Retain input and data,
// not view groups whose action callbacks belong to the unmounted palette.
export interface CommandPaletteReturnContext {
readonly query: string;
readonly linkedThreadSearch: Extract<CommandPaletteOpenIntent, { kind: "search" }> | null;
}

export interface CommandPaletteSearchState {
readonly query: string;
readonly views: ReadonlyArray<{
readonly view: CommandPaletteView;
readonly parentQuery: string;
}>;
}

export function createCommandPaletteSearchState(query = ""): CommandPaletteSearchState {
return { query, views: [] };
}

export function reduceCommandPaletteSearchState(
state: CommandPaletteSearchState,
action:
| { readonly type: "query"; readonly query: string }
| { readonly type: "push"; readonly view: CommandPaletteView }
| { readonly type: "back" }
| { readonly type: "reset"; readonly query?: string },
): CommandPaletteSearchState {
switch (action.type) {
case "query":
return { ...state, query: action.query };
case "push":
return {
query: action.view.initialQuery ?? "",
views: [...state.views, { view: action.view, parentQuery: state.query }],
};
case "back": {
const parent = state.views.at(-1);
return parent ? { query: parent.parentQuery, views: state.views.slice(0, -1) } : state;
}
case "reset":
return createCommandPaletteSearchState(action.query);
}
}

export function enumerateCommandPaletteItems(
items: ReadonlyArray<CommandPaletteActionItem>,
): CommandPaletteActionItem[] {
Expand Down
Loading
Loading