Skip to content
144 changes: 144 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import {
filterCommandPaletteGroups,
findHighlightedCommandPaletteItem,
reduceCommandPaletteUiState,
createCommandPaletteSearchState,
reduceCommandPaletteSearchState,
type CommandPaletteActionItem,
type CommandPaletteGroup,
type CommandPaletteUiState,
} from "./CommandPalette.logic";

describe("linked pull request thread navigation", () => {
Expand Down Expand Up @@ -894,3 +897,144 @@ describe("virtualized command palette rows", () => {
expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull();
});
});

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

it("Back restores the query that led into each nested picker", () => {
let state = createCommandPaletteSearchState(">new thread");
state = reduceCommandPaletteSearchState(state, { type: "push", view: picker });
expect(state.query).toBe("");
state = reduceCommandPaletteSearchState(state, { type: "query", query: "remote project" });
state = reduceCommandPaletteSearchState(state, {
type: "push",
view: { ...picker, initialQuery: "~/" },
});
expect(state.query).toBe("~/");
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's prefilled query before Back keeps the parent query", () => {
let state = createCommandPaletteSearchState(">add project");
state = reduceCommandPaletteSearchState(state, {
type: "push",
view: { ...picker, initialQuery: "~/" },
});
state = reduceCommandPaletteSearchState(state, { type: "query", query: "" });
state = reduceCommandPaletteSearchState(state, { type: "back" });
expect(state.query).toBe(">add project");
});

it("a reset drops the pickers it replaces", () => {
const nested = reduceCommandPaletteSearchState(createCommandPaletteSearchState("old"), {
type: "push",
view: picker,
});
const reset = reduceCommandPaletteSearchState(nested, { type: "reset", query: "linked" });
expect(reduceCommandPaletteSearchState(reset, { type: "back" })).toEqual(
createCommandPaletteSearchState("linked"),
);
});
});

describe("returning from file and content search", () => {
const root: CommandPaletteUiState = { open: true, mode: "command", openIntent: null };
const context = { query: "go to file", linkedThreadSearch: null };

it.each(["files", "content"] as const)("Escape from %s restores the palette query", (mode) => {
const child = reduceCommandPaletteUiState(root, { _tag: "OpenChildSearch", mode, context });
expect(child.mode).toBe(mode);
const returned = reduceCommandPaletteUiState(child, { _tag: "BackToCommand" });
expect(returned).toEqual({ ...root, restoredSearch: context });
});

it("restores linked-thread search results with the query", () => {
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);
});

it("closing, a fresh shortcut, or a direct intent forgets the parent query", () => {
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);

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();
});

it.each([
{ _tag: "OpenAddProject" },
{ _tag: "OpenNewThreadIn" },
{ _tag: "OpenChangeTheme" },
{ _tag: "OpenSearch", query: "fresh" },
] as const)("$_tag from a child search forgets the parent query", (action) => {
const child = reduceCommandPaletteUiState(root, {
_tag: "OpenChildSearch",
mode: "content",
context,
});
const direct = reduceCommandPaletteUiState(child, action);
expect(direct.mode).toBe("command");
expect(direct.parentSearch).toBeUndefined();
expect(direct.restoredSearch).toBeUndefined();
});

it("a second Back in the same keypress keeps the restored query", () => {
const child = reduceCommandPaletteUiState(root, {
_tag: "OpenChildSearch",
mode: "files",
context,
});
const returned = reduceCommandPaletteUiState(child, { _tag: "BackToCommand" });
expect(reduceCommandPaletteUiState(returned, { _tag: "BackToCommand" })).toBe(returned);
});

it("keeps linked-thread results across repeated trips into search", () => {
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: [] },
},
};
let state = root;
for (const mode of ["files", "content", "files"] as const) {
// The remounted palette hands its restored context to the next child search.
state = reduceCommandPaletteUiState(state, {
_tag: "OpenChildSearch",
mode,
context: state.restoredSearch ?? linkedContext,
});
state = reduceCommandPaletteUiState(state, { _tag: "BackToCommand" });
expect(state.restoredSearch).toEqual(linkedContext);
}
});
});
71 changes: 68 additions & 3 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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 @@ -78,7 +80,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 @@ -88,11 +96,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 @@ -162,6 +181,52 @@ export interface CommandPaletteView {
readonly initialQuery?: string;
}

// File and content search replace the palette, so Escape remounts it. Keep
// its input and linked-thread data to restore, not views whose actions
// belonged to the unmounted palette.
export interface CommandPaletteReturnContext {
readonly query: string;
readonly linkedThreadSearch: Extract<CommandPaletteOpenIntent, { kind: "search" }> | null;
}

// Each nested view remembers the query that led into it, so Back restores it.
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 type CommandPaletteRow =
| {
readonly kind: "label";
Expand Down
Loading
Loading