diff --git a/packages/app/e2e/browser/command-center-file-search.spec.ts b/packages/app/e2e/browser/command-center-file-search.spec.ts new file mode 100644 index 000000000..5964dcc97 --- /dev/null +++ b/packages/app/e2e/browser/command-center-file-search.spec.ts @@ -0,0 +1,139 @@ +import { expect, test } from "../support/fixtures"; +import { openCommandCenter } from "../support/helpers/command-center"; +import { + delayDirectorySuggestionResponses, + expectOneLineTruncatedFileResult, + expectStableCommandCenterLayout, + failDirectorySuggestionRequests, + startCommandCenterLayoutObservation, +} from "../support/helpers/command-center-file-search"; +import { expectFileTabOpen } from "../support/helpers/file-explorer"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; + +const FILE_PATH = + "packages/app/src/command-center/features/workspace-file-search/layout-stability/needle-layout-stability-command-center.tsx"; +const FILE_NAME = "needle-layout-stability-command-center.tsx"; +const FILE_DIRECTORY = + "packages/app/src/command-center/features/workspace-file-search/layout-stability"; + +function paseoSizedFiles(): Array<{ path: string; content: string }> { + const files = Array.from({ length: 120 }, (_, index) => ({ + path: `packages/app/src/features/feature-${String(index).padStart(3, "0")}/index.ts`, + content: `export const feature${index} = ${index};\n`, + })); + files.push({ path: FILE_PATH, content: "export const layoutNeedle = true;\n" }); + return files; +} + +async function seedMatchingWorkspaces(count: number): Promise { + return Promise.all( + Array.from({ length: count }, (_, index) => + seedWorkspace({ + repoPrefix: `command-center-file-search-error-${String(index).padStart(2, "0")}-`, + title: `Home failure ${String(index).padStart(2, "0")}`, + }), + ), + ); +} + +test.use({ + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36", +}); + +test("workspace file search stays geometrically stable through delayed loading and results", async ({ + page, +}) => { + test.setTimeout(120_000); + const seeded = await seedWorkspace({ + repoPrefix: "command-center-file-search-", + title: "Paseo-shaped file search", + repo: { files: paseoSizedFiles() }, + }); + + try { + await delayDirectorySuggestionResponses(page, 800); + await gotoWorkspace(page, seeded.workspaceId); + await page.keyboard.press("Meta+P"); + + const panel = page.getByTestId("command-center-panel"); + await expect(panel).toBeVisible({ timeout: 30_000 }); + await expect(panel.getByRole("button", { name: "Files" })).toBeVisible(); + await startCommandCenterLayoutObservation(page); + + await panel.getByTestId("command-center-input").fill(FILE_NAME); + const row = panel.getByRole("button", { + name: `${FILE_NAME} ${FILE_DIRECTORY}`, + }); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expectOneLineTruncatedFileResult(row, { + filename: FILE_NAME, + path: FILE_DIRECTORY, + }); + + await panel.getByTestId("command-center-input").fill("needle-layout"); + await page.waitForTimeout(150); + expect(await row.isVisible()).toBe(true); + await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible(); + await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0); + await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden(); + await expectStableCommandCenterLayout(page); + + await row.click(); + await expectFileTabOpen(page, FILE_PATH); + } finally { + await seeded.cleanup(); + } +}); + +test("dropping the files scope leaves the search row the same height", async ({ page }) => { + const seeded = await seedWorkspace({ + repoPrefix: "command-center-file-search-scope-", + title: "Scope chip height", + }); + + try { + await gotoWorkspace(page, seeded.workspaceId); + await page.keyboard.press("Meta+P"); + + const panel = page.getByTestId("command-center-panel"); + await expect(panel).toBeVisible({ timeout: 30_000 }); + const header = panel.getByTestId("command-center-header"); + const scoped = await header.boundingBox(); + + await page.getByTestId("command-center-files-scope").click(); + await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0); + const unscoped = await header.boundingBox(); + + expect(scoped?.height).toBe(unscoped?.height); + } finally { + await seeded.cleanup(); + } +}); + +test("unscoped command search keeps commands visible and reports file-search failures", async ({ + page, +}) => { + test.setTimeout(120_000); + const seeded = await seedMatchingWorkspaces(9); + + try { + await failDirectorySuggestionRequests(page); + await gotoWorkspace(page, seeded[0].workspaceId); + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill("home"); + + await expect(panel.getByRole("button", { name: "Home", exact: true })).toBeVisible(); + await expect(panel.getByRole("button", { name: /Home failure 08/ })).toBeVisible(); + await expect(panel.getByTestId("command-center-file-search-error")).toHaveText( + "Error: Test file search transport failure.", + ); + await expect(panel.getByTestId("command-center-file-search-error")).toBeInViewport({ + ratio: 1, + }); + await expect(panel.getByText("No matches", { exact: true })).toHaveCount(0); + } finally { + await Promise.allSettled(seeded.map((workspace) => workspace.cleanup())); + } +}); diff --git a/packages/app/e2e/support/helpers/command-center-file-search.ts b/packages/app/e2e/support/helpers/command-center-file-search.ts new file mode 100644 index 000000000..781470b58 --- /dev/null +++ b/packages/app/e2e/support/helpers/command-center-file-search.ts @@ -0,0 +1,211 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; + +type WebSocketMessage = string | Buffer; + +interface SurfaceRect { + height: number; + width: number; + x: number; + y: number; +} + +interface LayoutObservation { + active: boolean; + layoutShift: number; + samples: Record; +} + +type LayoutOracleWindow = Window & { + __commandCenterLayoutObservation?: LayoutObservation; +}; + +const SURFACE_SELECTORS = { + panel: '[data-testid="command-center-panel"]', + header: '[data-testid="command-center-header"]', + input: '[data-testid="command-center-input"]', + fileSearchStatus: '[data-testid="command-center-file-search-status"]', + results: '[data-testid="command-center-results"]', + scope: '[data-testid="command-center-files-scope"]', + fileRow: '[data-testid^="command-center-file-row-"]', + fileIcon: '[data-testid="command-center-file-icon"]', + fileLine: '[data-testid="command-center-file-line"]', +} as const; + +function parseSessionMessage(message: WebSocketMessage): Record | null { + const raw = typeof message === "string" ? message : message.toString("utf8"); + try { + const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown }; + return envelope.type === "session" && envelope.message && typeof envelope.message === "object" + ? (envelope.message as Record) + : null; + } catch { + return null; + } +} + +export async function delayDirectorySuggestionResponses(page: Page, delayMs = 400): Promise { + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + const sessionMessage = parseSessionMessage(message); + if (sessionMessage?.type === "directory_suggestions_response") { + setTimeout(() => ws.send(message), delayMs); + return; + } + ws.send(message); + }); + }); +} + +export async function failDirectorySuggestionRequests(page: Page): Promise { + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => { + const sessionMessage = parseSessionMessage(message); + if (sessionMessage?.type === "directory_suggestions_request") { + const requestId = sessionMessage.requestId; + if (typeof requestId === "string") { + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "rpc_error", + payload: { + requestId, + requestType: "directory_suggestions_request", + error: "Test file search transport failure.", + code: "transport", + }, + }, + }), + ); + } + return; + } + server.send(message); + }); + server.onMessage((message) => ws.send(message)); + }); +} + +export async function startCommandCenterLayoutObservation(page: Page): Promise { + await page.evaluate((selectors) => { + const oracleWindow = window as LayoutOracleWindow; + const observation: LayoutObservation = { active: true, layoutShift: 0, samples: {} }; + oracleWindow.__commandCenterLayoutObservation = observation; + + const observedElements = new WeakSet(); + const resizeObserver = new ResizeObserver(record); + const mutationObserver = new MutationObserver(discover); + + function record(): void { + if (!observation.active) return; + for (const [name, selector] of Object.entries(selectors)) { + const element = document.querySelector(selector); + if (!element) continue; + const rect = element.getBoundingClientRect(); + (observation.samples[name] ??= []).push({ + height: rect.height, + width: rect.width, + x: rect.x, + y: rect.y, + }); + } + } + + function discover(): void { + for (const selector of Object.values(selectors)) { + const element = document.querySelector(selector); + if (!element || observedElements.has(element)) continue; + observedElements.add(element); + resizeObserver.observe(element); + } + record(); + } + + mutationObserver.observe(document.body, { attributes: true, childList: true, subtree: true }); + discover(); + + const sampleFrame = () => { + if (!observation.active) { + mutationObserver.disconnect(); + resizeObserver.disconnect(); + return; + } + record(); + requestAnimationFrame(sampleFrame); + }; + requestAnimationFrame(sampleFrame); + + if (typeof PerformanceObserver !== "undefined") { + try { + const performanceObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0; + } + }); + performanceObserver.observe({ type: "layout-shift", buffered: false }); + } catch { + // LayoutShift is diagnostic-only on engines that do not expose it. + } + } + }, SURFACE_SELECTORS); +} + +export async function expectStableCommandCenterLayout(page: Page): Promise { + await page.waitForTimeout(500); + const observation = await page.evaluate(() => { + const state = (window as LayoutOracleWindow).__commandCenterLayoutObservation; + if (!state) throw new Error("Command center layout observer was not installed"); + state.active = false; + return state; + }); + + for (const surface of Object.keys(SURFACE_SELECTORS)) { + const samples = observation.samples[surface] ?? []; + expect(samples.length, `${surface}: ${JSON.stringify(observation)}`).toBeGreaterThan(0); + for (const dimension of ["height", "width", "x", "y"] as const) { + const values = samples.map((sample) => sample[dimension]); + expect( + Math.max(...values) - Math.min(...values), + `${surface}.${dimension}: ${JSON.stringify(samples)}`, + ).toBeLessThanOrEqual(1); + } + } + expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001); +} + +export async function expectOneLineTruncatedFileResult( + row: Locator, + input: { filename: string; path: string }, +): Promise { + await expect(row).toContainText(`${input.filename} ${input.path}`); + await expect(row.getByTestId("command-center-file-icon")).toBeVisible(); + const line = row.getByTestId("command-center-file-line"); + const filename = row.getByTestId("command-center-file-name"); + const filePath = row.getByTestId("command-center-file-path"); + const geometry = await Promise.all([ + row.boundingBox(), + line.boundingBox(), + filename.boundingBox(), + filePath.boundingBox(), + line.evaluate((element) => ({ + clientWidth: element.clientWidth, + overflow: getComputedStyle(element).overflow, + scrollWidth: element.scrollWidth, + whiteSpace: getComputedStyle(element).whiteSpace, + })), + ]); + const [rowBox, lineBox, filenameBox, pathBox, overflow] = geometry; + expect(rowBox).not.toBeNull(); + expect(lineBox).not.toBeNull(); + expect(filenameBox).not.toBeNull(); + expect(pathBox).not.toBeNull(); + expect(rowBox?.height).toBe(36); + expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1); + expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth); + expect(overflow.overflow).toBe("hidden"); + expect(overflow.whiteSpace).toBe("nowrap"); +} diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 051655bc5..0daac4cb2 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -13,7 +13,7 @@ import { } from "react-native"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Check, ChevronRight, Folder } from "lucide-react-native"; +import { Check, ChevronRight, Folder, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { BottomSheetBackdrop, @@ -22,6 +22,8 @@ import { type BottomSheetFlatListMethods, } from "@gorhom/bottom-sheet"; import { AgentStatusDot } from "@/components/agent-status-dot"; +import { MaterialFileIcon } from "@/components/material-file-icon"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Shortcut } from "@/components/ui/shortcut"; import { IsolatedBottomSheetModal, @@ -37,7 +39,10 @@ import { useWebOverlayRegistration, } from "@/lib/overlay-root"; import { useHosts } from "@/runtime/host-runtime"; -import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { + useKeyboardShortcutsStore, + type CommandCenterScope, +} from "@/stores/keyboard-shortcuts-store"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { @@ -56,11 +61,13 @@ import { preserveActiveResultId, projectCommandCenterRows, type CommandCenterAgentResult, + type CommandCenterFileResult, type CommandCenterListRow, type CommandCenterResult, type CommandCenterResultSection, type CommandCenterWorkspaceResult, } from "./results"; +import { useWorkspaceFileSearch } from "./workspace-file-search"; const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({ placeholderTextColor: theme.colors.foregroundMuted, @@ -73,6 +80,10 @@ const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreg const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({ color: theme.colors.foregroundMuted, })); +const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMuted })); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({ + color: theme.colors.foregroundMuted, +})); const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"]; const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const; const DEFAULT_CATEGORY_RESULT_LIMIT = 5; @@ -191,6 +202,8 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe interface CommandCenterState { open: boolean; + scope: CommandCenterScope; + clearScope(): void; query: string; setQuery(query: string): void; activeId: string | null; @@ -199,27 +212,58 @@ interface CommandCenterState { rowIndexByResultId: ReadonlyMap; offsets: readonly number[]; inputRef: React.RefObject; + fileSearchLoading: boolean; + fileSearchError: string | null; close(): void; select(result: CommandCenterResult): void; key(key: string): boolean; } function useCommandCenterState(): CommandCenterState { + const { t } = useTranslation(); const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen); + const scope = useKeyboardShortcutsStore((state) => state.commandCenterScope); const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); + const setScope = useKeyboardShortcutsStore((state) => state.setCommandCenterScope); const snapshot = useCommandCenterContributions(); const inputRef = useRef(null); const previousOpenRef = useRef(open); const [query, setQuery] = useState(""); const [activeId, setActiveId] = useState(null); const builtInSections = useBuiltInSections(open, query); + const { + entries: fileSearchEntries, + loading: fileSearchLoading, + error: fileSearchError, + openFile, + } = useWorkspaceFileSearch({ + enabled: open && (scope === "files" || Boolean(query.trim())), + query, + }); + const fileSections = useMemo(() => { + const results = fileSearchEntries.map((entry) => ({ + kind: "file", + id: `file:${entry.path}`, + filePath: entry.path, + title: entry.name, + subtitle: entry.directory, + searchText: entry.path.toLowerCase(), + run: () => openFile(entry.path), + })); + return [{ id: "files", rank: 4, title: t("shell.commandCenter.files"), results }]; + }, [fileSearchEntries, openFile, t]); const contributionSections = useMemo( () => buildContributionSections(snapshot.contributions, query), [query, snapshot.contributions], ); const projection = useMemo( - () => projectCommandCenterRows([...contributionSections, ...builtInSections]), - [builtInSections, contributionSections], + () => + projectCommandCenterRows( + scope === "files" + ? fileSections + : [...contributionSections, ...fileSections, ...builtInSections], + ), + [builtInSections, contributionSections, fileSections, scope], ); const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults); @@ -239,6 +283,10 @@ function useCommandCenterState(): CommandCenterState { close(); return true; } + if (pressed === "Backspace" && !query.trim() && scope) { + setScope(null); + return true; + } if (pressed === "Enter") { const selected = results.find((result) => result.id === resolvedActiveId); if (!selected) return false; @@ -251,7 +299,7 @@ function useCommandCenterState(): CommandCenterState { setActiveId(moveActiveResultId(resolvedActiveId, results, direction)); return true; }, - [close, open, projection.selectableResults, resolvedActiveId, select], + [close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope], ); useEffect(() => { @@ -277,6 +325,8 @@ function useCommandCenterState(): CommandCenterState { return { open, + scope, + clearScope: () => setScope(null), query, setQuery, activeId: resolvedActiveId, @@ -285,6 +335,8 @@ function useCommandCenterState(): CommandCenterState { rowIndexByResultId: projection.rowIndexByResultId, offsets: projection.offsets, inputRef, + fileSearchLoading, + fileSearchError, close, select, key, @@ -303,7 +355,10 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo result.kind === "contribution" && result.contribution.presentation.kind === "choice" ? result.contribution.presentation : null; - const accessibilityLabel = choice?.path.join(" › "); + const accessibilityLabel = + result.kind === "file" + ? [result.title, result.subtitle].filter(Boolean).join(" ") + : choice?.path.join(" › "); const accessibilityState = useMemo( () => (isNative && choice ? { selected: choice.selected } : undefined), [choice], @@ -329,7 +384,9 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo accessibilityLabel={accessibilityLabel} accessibilityState={accessibilityState} aria-pressed={isWeb ? choice?.selected : undefined} - testID={choice?.testId} + testID={ + result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId + } > @@ -337,6 +394,28 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo }); function ResultContent({ result }: { result: CommandCenterResult }) { + if (result.kind === "file") { + return ( + + + + + + + + {result.title} + + {result.subtitle ? ( + + {" "} + {result.subtitle} + + ) : null} + + + + ); + } if (result.kind === "agent") { const agent = result.agent; return ( @@ -545,8 +624,24 @@ export function CommandCenter() { ); const keyExtractor = useCallback((row: CommandCenterListRow) => row.key, []); const empty = useMemo( - () => {t("shell.commandCenter.noMatches")}, - [t], + () => + state.fileSearchError || state.fileSearchLoading ? null : ( + {t("shell.commandCenter.noMatches")} + ), + [state.fileSearchError, state.fileSearchLoading, t], + ); + const fileSearchError = useMemo( + () => + state.fileSearchError ? ( + + {t("common.errors.error")}: {state.fileSearchError} + + ) : null, + [state.fileSearchError, t], ); const commonListProps = { data: state.rows, @@ -554,6 +649,8 @@ export function CommandCenter() { keyExtractor, getItemLayout, ListEmptyComponent: empty, + style: styles.results, + testID: "command-center-results", keyboardShouldPersistTaps: KEYBOARD_SHOULD_PERSIST_TAPS, showsVerticalScrollIndicator: false, initialNumToRender: 12, @@ -606,7 +703,10 @@ export function CommandCenter() { keyboardBlurBehavior="restore" accessible={false} > - + + {state.scope === "files" ? ( + + ) : null} + + {fileSearchError} ); @@ -632,20 +741,32 @@ export function CommandCenter() { - + + {state.scope === "files" ? ( + + ) : null} + - + {fileSearchError} + @@ -653,6 +774,41 @@ export function CommandCenter() { ); } +function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; label: string }) { + return ( + + {loading ? ( + + + + ) : null} + + ); +} + +// The chip sits at the input's own height and type size so it reads as the scope of the field +// rather than a badge dropped into the query. Its 28px matches the input's line box, so showing or +// dropping the scope cannot resize the header. +function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) { + return ( + + + {label} + + + ); +} + const styles = StyleSheet.create((theme) => ({ overlay: { flex: 1, @@ -663,6 +819,7 @@ const styles = StyleSheet.create((theme) => ({ backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" }, panel: { width: 640, + height: 560, maxWidth: "92%", maxHeight: "80%", borderWidth: 1, @@ -690,7 +847,29 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, outlineWidth: 0, }, - results: { flexGrow: 0 }, + growingInput: { flex: 1, minWidth: 0 }, + searchRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + fileSearchStatus: { width: 14, height: 14, alignItems: "center", justifyContent: "center" }, + scopeChip: { + // Stretching to the row rather than setting a height keeps the chip exactly as tall as the + // input's line box, so showing or dropping the scope can never resize the header. + alignSelf: "stretch", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + marginRight: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.borderAccent, + backgroundColor: theme.colors.surface2, + }, + scopeChipLabel: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: "500", + }, + results: { flex: 1 }, sectionLabel: { paddingHorizontal: theme.spacing[4], paddingBottom: theme.spacing[2], @@ -726,6 +905,9 @@ const styles = StyleSheet.create((theme) => ({ lineHeight: 18, flexShrink: 1, }, + fileLine: { flex: 1, minWidth: 0, fontSize: theme.fontSize.sm, lineHeight: 20 }, + fileName: { color: theme.colors.foreground }, + filePath: { color: theme.colors.foregroundMuted }, subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 16 }, iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" }, rowShortcut: { flexShrink: 0 }, @@ -755,6 +937,14 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + errorText: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + color: theme.colors.statusDanger, + fontSize: theme.fontSize.sm, + }, sheetBackground: { backgroundColor: theme.colors.surface0 }, sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] }, titledSection: { height: 32, justifyContent: "flex-end" }, diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts index da3ea5c25..e0c610795 100644 --- a/packages/app/src/command-center/results.ts +++ b/packages/app/src/command-center/results.ts @@ -20,6 +20,16 @@ export interface CommandCenterAgentResult { run(): void; } +export interface CommandCenterFileResult { + kind: "file"; + id: string; + filePath: string; + title: string; + subtitle: string; + searchText: string; + run(): void; +} + export interface CommandCenterContributionResult { kind: "contribution"; id: string; @@ -31,6 +41,7 @@ export interface CommandCenterContributionResult { export type CommandCenterResult = | CommandCenterWorkspaceResult | CommandCenterAgentResult + | CommandCenterFileResult | CommandCenterContributionResult; export interface CommandCenterResultSection { @@ -78,6 +89,7 @@ function contributionSearchText(contribution: CommandCenterContribution): string function resultHeight(result: CommandCenterResult): number { if (result.kind === "workspace" || result.kind === "agent") return 56; + if (result.kind === "file") return 36; if (result.contribution.presentation.kind === "action") { return result.contribution.presentation.subtitle ? 56 : 36; } diff --git a/packages/app/src/command-center/workspace-file-search-model.ts b/packages/app/src/command-center/workspace-file-search-model.ts new file mode 100644 index 000000000..4f43feaf7 --- /dev/null +++ b/packages/app/src/command-center/workspace-file-search-model.ts @@ -0,0 +1,15 @@ +export interface WorkspaceFileSearchEntry { + path: string; + name: string; + directory: string; +} + +export function describeWorkspaceFilePath(path: string): WorkspaceFileSearchEntry { + const normalized = path.replace(/\\/g, "/"); + const separator = normalized.lastIndexOf("/"); + return { + path: normalized, + name: separator >= 0 ? normalized.slice(separator + 1) : normalized, + directory: separator >= 0 ? normalized.slice(0, separator) : "", + }; +} diff --git a/packages/app/src/command-center/workspace-file-search.test.ts b/packages/app/src/command-center/workspace-file-search.test.ts new file mode 100644 index 000000000..6070ce157 --- /dev/null +++ b/packages/app/src/command-center/workspace-file-search.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { describeWorkspaceFilePath } from "./workspace-file-search-model"; + +describe("describeWorkspaceFilePath", () => { + it("separates a workspace-relative file into its row labels", () => { + expect(describeWorkspaceFilePath("src/components/message.tsx")).toEqual({ + path: "src/components/message.tsx", + name: "message.tsx", + directory: "src/components", + }); + }); + + it("normalizes Windows separators before opening or presenting a file", () => { + expect(describeWorkspaceFilePath("src\\components\\message.tsx")).toEqual({ + path: "src/components/message.tsx", + name: "message.tsx", + directory: "src/components", + }); + }); + + it("keeps root files free of a redundant directory label", () => { + expect(describeWorkspaceFilePath("package.json")).toEqual({ + path: "package.json", + name: "package.json", + directory: "", + }); + }); +}); diff --git a/packages/app/src/command-center/workspace-file-search.ts b/packages/app/src/command-center/workspace-file-search.ts new file mode 100644 index 000000000..7a536159b --- /dev/null +++ b/packages/app/src/command-center/workspace-file-search.ts @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; +import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { usePanelStore } from "@/stores/panel-store"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { + describeWorkspaceFilePath, + type WorkspaceFileSearchEntry, +} from "./workspace-file-search-model"; + +interface DirectorySuggestionEntry { + path: string; +} + +const FILE_SEARCH_DEBOUNCE_MS = 100; +const FILE_SEARCH_LIMIT = 100; + +interface WorkspaceFileSearchState { + sourceKey: string | null; + requestKey: string | null; + entries: readonly WorkspaceFileSearchEntry[]; + loading: boolean; + error: string | null; +} + +const EMPTY_STATE: WorkspaceFileSearchState = { + sourceKey: null, + requestKey: null, + entries: [], + loading: false, + error: null, +}; + +function errorMessage(error: unknown): string { + if (!(error instanceof Error)) return String(error); + if (error.name !== "DaemonRpcError") return error.message; + return error.message.replace(/ requestType=\S+(?: code=\S+)?$/, ""); +} + +function describeFileEntries( + entries: readonly DirectorySuggestionEntry[], +): WorkspaceFileSearchEntry[] { + return entries.map(({ path }) => describeWorkspaceFilePath(path)); +} + +export function useWorkspaceFileSearch(input: { enabled: boolean; query: string }): { + entries: readonly WorkspaceFileSearchEntry[]; + loading: boolean; + error: string | null; + openFile(path: string): void; +} { + const selection = useActiveWorkspaceSelection(); + const serverId = selection?.serverId ?? null; + const workspaceId = selection?.workspaceId ?? null; + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const client = useSessionStore((state) => + serverId ? (state.sessions[serverId]?.client ?? null) : null, + ); + const [state, setState] = useState(EMPTY_STATE); + const sourceKey = useMemo( + () => (serverId && workspaceId && cwd && client ? `${serverId}\0${workspaceId}\0${cwd}` : null), + [client, cwd, serverId, workspaceId], + ); + const requestKey = useMemo( + () => (input.enabled && sourceKey ? `${sourceKey}\0${input.query}` : null), + [input.enabled, input.query, sourceKey], + ); + + useEffect(() => { + if (!requestKey || !client || !cwd) { + setState(EMPTY_STATE); + return; + } + const activeClient = client; + const activeCwd = cwd; + + let cancelled = false; + setState((previous) => ({ + sourceKey, + requestKey, + entries: previous.sourceKey === sourceKey ? previous.entries : [], + loading: true, + error: null, + })); + async function search(): Promise { + try { + const payload = await activeClient.getDirectorySuggestions({ + cwd: activeCwd, + query: input.query, + includeFiles: true, + includeDirectories: false, + limit: FILE_SEARCH_LIMIT, + }); + if (cancelled) return; + setState({ + sourceKey, + requestKey, + entries: payload.error ? [] : describeFileEntries(payload.entries), + loading: false, + error: payload.error ?? null, + }); + } catch (error) { + if (!cancelled) { + setState({ + sourceKey, + requestKey, + entries: [], + loading: false, + error: errorMessage(error), + }); + } + } + } + + const timer = setTimeout(() => void search(), input.query.trim() ? FILE_SEARCH_DEBOUNCE_MS : 0); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [client, cwd, input.query, requestKey, sourceKey]); + + const openFile = useCallback( + (path: string) => { + if (!serverId || !workspaceId) return; + clearCommandCenterFocusRestoreElement(); + openWorkspaceFileFromExplorer({ + filePath: path, + persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), + showMobileAgent: usePanelStore.getState().showMobileAgent, + openWorkspaceTabFocused: useWorkspaceLayoutStore.getState().openTabFocused, + focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab, + }); + }, + [serverId, workspaceId], + ); + + return { + entries: requestKey && state.sourceKey === sourceKey ? state.entries : [], + loading: Boolean(requestKey) && (state.requestKey !== requestKey || state.loading), + error: state.requestKey === requestKey ? state.error : null, + openFile, + }; +} diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 6c5785b48..d7abeb1db 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -201,7 +201,9 @@ export function useKeyboardShortcuts({ setCommandCenterFocusRestoreElement(browserFocusRestoreElement); } } - useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen); + useKeyboardShortcutsStore + .getState() + .setCommandCenterOpen(action.nextOpen, action.scope ?? null); return true; } case "shortcuts-dialog-toggle": diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 1c0ff7b8c..ca2c42f73 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -190,7 +190,11 @@ describe("translation resources", () => { expect(en.shell.menu.toggleSidebar).toBe("Toggle sidebar"); expect(en.shell.menu.open).toBe("Open menu"); expect(en.shell.menu.close).toBe("Close menu"); - expect(en.shell.commandCenter.placeholder).toBe("Search commands, workspaces, and agents..."); + expect(en.shell.commandCenter.placeholder).toBe( + "Search commands, files, workspaces, and agents...", + ); + expect(en.shell.commandCenter.filePlaceholder).toBe("Search files..."); + expect(en.shell.commandCenter.files).toBe("Files"); expect(en.shell.commandCenter.noMatches).toBe("No matches"); expect(en.shell.commandCenter.actions).toBe("Actions"); expect(en.shell.commandCenter.workspaces).toBe("Workspaces"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 2329fea34..bc3c09323 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -54,9 +54,12 @@ export const ar: TranslationResources = { close: "إغلاق القائمة", }, commandCenter: { - placeholder: "ابحث في الأوامر ومساحات العمل والوكلاء...", + placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...", + filePlaceholder: "البحث في الملفات...", + searchingFiles: "جارٍ البحث في الملفات...", noMatches: "لا توجد مباريات", actions: "الإجراءات", + files: "الملفات", workspaces: "مساحات العمل", agents: "الوكلاء", newAgent: "وكيل جديد", @@ -1912,10 +1915,10 @@ export const ar: TranslationResources = { resetAll: "إعادة ضبط الكل", }, sections: { - navigation: "ملاحة", + general: "عام", + workspaces: "المشاريع ومساحات العمل", tabsPanes: "علامات التبويب والأجزاء", - projects: "المشاريع", - panels: "لوحات", + layout: "التخطيط", agentInput: "إدخال Agent", }, help: { @@ -1943,6 +1946,7 @@ export const ar: TranslationResources = { moveTabDown: "حرك علامة التبويب لأسفل", closePane: "إغلاق الجزء", newTerminal: "محطة جديدة", + searchFiles: "البحث في الملفات", toggleCommandCenter: "تبديل مركز القيادة", showKeyboardShortcuts: "إظهار اختصارات لوحة المفاتيح", toggleLeftSidebar: "تبديل الشريط الجانبي الأيسر", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 357ccef58..a8694a551 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -52,9 +52,12 @@ export const en = { close: "Close menu", }, commandCenter: { - placeholder: "Search commands, workspaces, and agents...", + placeholder: "Search commands, files, workspaces, and agents...", + filePlaceholder: "Search files...", + searchingFiles: "Searching files...", noMatches: "No matches", actions: "Actions", + files: "Files", workspaces: "Workspaces", agents: "Agents", newAgent: "New agent", @@ -1922,10 +1925,10 @@ export const en = { resetAll: "Reset all", }, sections: { - navigation: "Navigation", + general: "General", + workspaces: "Projects & Workspaces", tabsPanes: "Tabs & Panes", - projects: "Projects", - panels: "Panels", + layout: "Layout", agentInput: "Agent Input", }, help: { @@ -1953,6 +1956,7 @@ export const en = { moveTabDown: "Move tab down", closePane: "Close pane", newTerminal: "New terminal", + searchFiles: "Search files", toggleCommandCenter: "Toggle command center", showKeyboardShortcuts: "Show keyboard shortcuts", toggleLeftSidebar: "Toggle left sidebar", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index af64e4e76..244dec9d1 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -54,9 +54,12 @@ export const es: TranslationResources = { close: "Cerrar menú", }, commandCenter: { - placeholder: "Buscar comandos, espacios de trabajo y agentes...", + placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...", + filePlaceholder: "Buscar archivos...", + searchingFiles: "Buscando archivos...", noMatches: "No hay coincidencias", actions: "Comportamiento", + files: "Archivos", workspaces: "Espacios de trabajo", agents: "Agentes", newAgent: "Nuevo agente", @@ -1960,10 +1963,10 @@ export const es: TranslationResources = { resetAll: "Restablecer todo", }, sections: { - navigation: "Navegación", + general: "General", + workspaces: "Proyectos y espacios de trabajo", tabsPanes: "Pestañas y paneles", - projects: "Proyectos", - panels: "Paneles", + layout: "Diseño", agentInput: "EntradaAgent", }, help: { @@ -1991,6 +1994,7 @@ export const es: TranslationResources = { moveTabDown: "Mover pestaña hacia abajo", closePane: "Cerrar panel", newTerminal: "Nueva terminal", + searchFiles: "Buscar archivos", toggleCommandCenter: "Alternar centro de comando", showKeyboardShortcuts: "Mostrar atajos de teclado", toggleLeftSidebar: "Alternar barra lateral izquierda", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 3cd4fb551..cde09ccb2 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -55,9 +55,12 @@ export const fr: TranslationResources = { close: "Fermer le menu", }, commandCenter: { - placeholder: "Rechercher des commandes, espaces de travail et agents...", + placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...", + filePlaceholder: "Rechercher des fichiers...", + searchingFiles: "Recherche de fichiers...", noMatches: "Aucune correspondance", actions: "Actes", + files: "Fichiers", workspaces: "Espaces de travail", agents: "Agents", newAgent: "Nouvel agent", @@ -1963,10 +1966,10 @@ export const fr: TranslationResources = { resetAll: "Tout réinitialiser", }, sections: { - navigation: "Navigation", + general: "Général", + workspaces: "Projets et espaces de travail", tabsPanes: "Onglets et volets", - projects: "Projets", - panels: "Panneaux", + layout: "Disposition", agentInput: "EntréeAgent", }, help: { @@ -1994,6 +1997,7 @@ export const fr: TranslationResources = { moveTabDown: "Déplacer l'onglet vers le bas", closePane: "Fermer le volet", newTerminal: "Nouvelle borne", + searchFiles: "Rechercher des fichiers", toggleCommandCenter: "Basculer le centre de commande", showKeyboardShortcuts: "Afficher les raccourcis clavier", toggleLeftSidebar: "Basculer la barre latérale gauche", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index a4a5b139f..6aff6de07 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -54,9 +54,12 @@ export const ja: TranslationResources = { close: "メニューを閉じる", }, commandCenter: { - placeholder: "コマンド、ワークスペース、エージェントを検索...", + placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...", + filePlaceholder: "ファイルを検索...", + searchingFiles: "ファイルを検索中...", noMatches: "一致なし", actions: "アクション", + files: "ファイル", workspaces: "ワークスペース", agents: "エージェント", newAgent: "新しいエージェント", @@ -1928,10 +1931,10 @@ export const ja: TranslationResources = { resetAll: "すべてリセット", }, sections: { - navigation: "ナビゲーション", + general: "一般", + workspaces: "プロジェクト&ワークスペース", tabsPanes: "タブ&ペイン", - projects: "プロジェクト", - panels: "パネル", + layout: "レイアウト", agentInput: "エージェント入力", }, help: { @@ -1959,6 +1962,7 @@ export const ja: TranslationResources = { moveTabDown: "タブを下に移動", closePane: "ペインを閉じる", newTerminal: "新しいターミナル", + searchFiles: "ファイルを検索", toggleCommandCenter: "コマンドセンターを切り替え", showKeyboardShortcuts: "キーボードショートカットを表示", toggleLeftSidebar: "左サイドバーを切り替え", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 9d43e6ebc..aa1b9a9c8 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -54,9 +54,12 @@ export const ko: TranslationResources = { close: "메뉴 닫기", }, commandCenter: { - placeholder: "명령, 워크스페이스 및 에이전트 검색...", + placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...", + filePlaceholder: "파일 검색...", + searchingFiles: "파일 검색 중...", noMatches: "일치 항목 없음", actions: "작업", + files: "파일", workspaces: "워크스페이스", agents: "에이전트", newAgent: "새 에이전트", @@ -1923,10 +1926,10 @@ export const ko: TranslationResources = { resetAll: "모두 재설정", }, sections: { - navigation: "탐색", + general: "일반", + workspaces: "프로젝트 및 워크스페이스", tabsPanes: "탭 및 창", - projects: "프로젝트", - panels: "패널", + layout: "레이아웃", agentInput: "에이전트 입력", }, help: { @@ -1954,6 +1957,7 @@ export const ko: TranslationResources = { moveTabDown: "탭을 아래로 이동", closePane: "창 닫기", newTerminal: "새 터미널", + searchFiles: "파일 검색", toggleCommandCenter: "명령 센터 토글", showKeyboardShortcuts: "키보드 단축키 표시", toggleLeftSidebar: "왼쪽 사이드바 토글", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 3659ae9bc..1eaa5e994 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -54,9 +54,12 @@ export const ptBR: TranslationResources = { close: "Fechar menu", }, commandCenter: { - placeholder: "Buscar comandos, espaços de trabalho e agentes...", + placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...", + filePlaceholder: "Pesquisar arquivos...", + searchingFiles: "Pesquisando arquivos...", noMatches: "Nenhuma correspondência", actions: "Ações", + files: "Arquivos", workspaces: "Espaços de trabalho", agents: "Agentes", newAgent: "Novo agente", @@ -1943,10 +1946,10 @@ export const ptBR: TranslationResources = { resetAll: "Redefinir tudo", }, sections: { - navigation: "Navegação", + general: "Geral", + workspaces: "Projetos e workspaces", tabsPanes: "Abas e painéis", - projects: "Projetos", - panels: "Painéis", + layout: "Layout", agentInput: "Entrada do agente", }, help: { @@ -1974,6 +1977,7 @@ export const ptBR: TranslationResources = { moveTabDown: "Mover aba para baixo", closePane: "Fechar painel", newTerminal: "Novo terminal", + searchFiles: "Pesquisar arquivos", toggleCommandCenter: "Alternar central de comandos", showKeyboardShortcuts: "Mostrar atalhos de teclado", toggleLeftSidebar: "Alternar barra lateral esquerda", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index c4053e568..93427bd6a 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -54,9 +54,12 @@ export const ru: TranslationResources = { close: "Закрыть меню", }, commandCenter: { - placeholder: "Поиск команд, рабочих пространств и агентов...", + placeholder: "Поиск команд, файлов, рабочих пространств и агентов...", + filePlaceholder: "Поиск файлов...", + searchingFiles: "Поиск файлов...", noMatches: "Нет совпадений", actions: "Действия", + files: "Файлы", workspaces: "Рабочие пространства", agents: "Агенты", newAgent: "Новый агент", @@ -1950,10 +1953,10 @@ export const ru: TranslationResources = { resetAll: "Сбросить все", }, sections: { - navigation: "Навигация", + general: "Общие", + workspaces: "Проекты и рабочие пространства", tabsPanes: "Вкладки и панели", - projects: "Проекты", - panels: "Панели", + layout: "Макет", agentInput: "Вход Agent", }, help: { @@ -1981,6 +1984,7 @@ export const ru: TranslationResources = { moveTabDown: "Переместить вкладку вниз", closePane: "Закрыть панель", newTerminal: "Новый терминал", + searchFiles: "Поиск файлов", toggleCommandCenter: "Переключить командный центр", showKeyboardShortcuts: "Показать сочетания клавиш", toggleLeftSidebar: "Переключить левую боковую панель", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e4c1fbddb..561e64376 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -54,9 +54,12 @@ export const zhCN: TranslationResources = { close: "关闭菜单", }, commandCenter: { - placeholder: "搜索命令、工作区和 Agent...", + placeholder: "搜索命令、文件、工作区和 Agent...", + filePlaceholder: "搜索文件...", + searchingFiles: "正在搜索文件...", noMatches: "没有匹配项", actions: "操作", + files: "文件", workspaces: "工作区", agents: "Agents", newAgent: "新建 Agent", @@ -1890,10 +1893,10 @@ export const zhCN: TranslationResources = { resetAll: "全部重置", }, sections: { - navigation: "导航", + general: "通用", + workspaces: "项目和工作区", tabsPanes: "标签和窗格", - projects: "项目", - panels: "面板", + layout: "布局", agentInput: "Agent 输入", }, help: { @@ -1921,6 +1924,7 @@ export const zhCN: TranslationResources = { moveTabDown: "向下移动标签", closePane: "关闭窗格", newTerminal: "新建终端", + searchFiles: "搜索文件", toggleCommandCenter: "切换命令中心", showKeyboardShortcuts: "显示键盘快捷键", toggleLeftSidebar: "切换左侧边栏", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 813085257..5a0d86f84 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -41,6 +41,7 @@ export type KeyboardActionId = | "sidebar.toggle.both" | "settings.toggle" | "command-center.toggle" + | "command-center.files" | "shortcuts.dialog.toggle" | "workspace.terminal.new" | "workspace.new" diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index cf12f2c5d..69fd45b8b 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -9,6 +9,7 @@ import { parseBindingChord, resolveKeyboardShortcut, resolveShortcutKeysForAction, + SHORTCUT_HELP_ROW_ORDER, UNASSIGNED_COMBO, type ChordState, type KeyboardShortcutContext, @@ -149,16 +150,16 @@ describe("keyboard-shortcuts", () => { action: "workspace.new", }, { - name: "matches Cmd+P to switch project on mac", + name: "matches Cmd+P to search workspace files on mac", event: { key: "p", code: "KeyP", metaKey: true }, context: { isMac: true, commandCenterOpen: false }, - action: "workspace.project.pick", + action: "command-center.files", }, { - name: "matches Ctrl+P to switch project on non-mac", + name: "matches Ctrl+P to search workspace files on non-mac", event: { key: "p", code: "KeyP", ctrlKey: true }, context: { isMac: false, commandCenterOpen: false, focusScope: "other" }, - action: "workspace.project.pick", + action: "command-center.files", }, { name: "matches question-mark shortcut to toggle the shortcuts dialog", @@ -781,20 +782,63 @@ describe("keyboard-shortcut help sections", () => { it("returns stable i18n keys for section titles and help rows", () => { const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }); - const projects = sections.find((section) => section.id === "projects"); - const panels = sections.find((section) => section.id === "panels"); + const workspaces = sections.find((section) => section.id === "workspaces"); + const layout = sections.find((section) => section.id === "layout"); const openProject = findRow(sections, "new-agent"); const cycleAgentMode = findRow(sections, "cycle-agent-mode"); const showShortcuts = findRow(sections, "show-shortcuts"); - expect(projects?.titleKey).toBe("settings.shortcuts.sections.projects"); - expect(panels?.titleKey).toBe("settings.shortcuts.sections.panels"); + expect(workspaces?.titleKey).toBe("settings.shortcuts.sections.workspaces"); + expect(layout?.titleKey).toBe("settings.shortcuts.sections.layout"); expect(openProject?.labelKey).toBe("settings.shortcuts.help.openProject"); expect(openProject?.label).toBe("Open project"); expect(cycleAgentMode?.labelKey).toBe("settings.shortcuts.help.cycleAgentMode"); expect(showShortcuts?.noteKey).toBe("settings.shortcuts.helpNotes.showKeyboardShortcuts"); }); + it("gives every help row an explicit place in its section's order", () => { + const platforms = [ + { isMac: true, isDesktop: true }, + { isMac: false, isDesktop: true }, + { isMac: true, isDesktop: false }, + { isMac: false, isDesktop: false }, + ]; + const unplaced: string[] = []; + for (const platform of platforms) { + for (const section of buildKeyboardShortcutHelpSections(platform)) { + for (const row of section.rows) { + if (SHORTCUT_HELP_ROW_ORDER[section.id].includes(row.id)) continue; + unplaced.push(`${section.id}:${row.id}`); + } + } + } + + expect(unplaced).toEqual([]); + }); + + it("leads the general section with the command center and file search", () => { + const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }); + + expect(sections[0]?.id).toBe("general"); + expect(sections[0]?.rows.slice(0, 2).map((row) => row.id)).toEqual([ + "toggle-command-center", + "search-files", + ]); + }); + + it("reuses the project-picker binding ids for rebindable file search", () => { + expect(getBindingIdForAction("search-files", { isMac: true, isDesktop: true })).toBe( + "workspace-project-pick-cmd-p-mac", + ); + expect(getBindingIdForAction("search-files", { isMac: false, isDesktop: true })).toBe( + "workspace-project-pick-ctrl-p-non-mac", + ); + expect( + findRow(buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }), "search-files") + ?.chord, + ).not.toBeNull(); + }); + it("does not expose Enter send behavior as rebindable shortcut rows", () => { const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 6ca660c37..bd0afaf44 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -49,7 +49,7 @@ export interface KeyboardShortcutHelpRow { noteKey?: string; } -export type ShortcutSectionId = "navigation" | "tabs-panes" | "projects" | "panels" | "agent-input"; +export type ShortcutSectionId = "general" | "workspaces" | "tabs-panes" | "layout" | "agent-input"; export interface KeyboardShortcutHelpSection { id: ShortcutSectionId; @@ -124,22 +124,81 @@ export interface ChordState { // --- Constants --- +// Sections are listed in this order, most-reached-for first. +const SHORTCUT_HELP_SECTION_ORDER: readonly ShortcutSectionId[] = [ + "general", + "workspaces", + "tabs-panes", + "layout", + "agent-input", +]; + const SHORTCUT_HELP_SECTION_TITLES: Record = { - navigation: "Navigation", + general: "General", + workspaces: "Projects & Workspaces", "tabs-panes": "Tabs & Panes", - projects: "Projects", - panels: "Panels", + layout: "Layout", "agent-input": "Agent Input", }; const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record = { - navigation: "settings.shortcuts.sections.navigation", + general: "settings.shortcuts.sections.general", + workspaces: "settings.shortcuts.sections.workspaces", "tabs-panes": "settings.shortcuts.sections.tabsPanes", - projects: "settings.shortcuts.sections.projects", - panels: "settings.shortcuts.sections.panels", + layout: "settings.shortcuts.sections.layout", "agent-input": "settings.shortcuts.sections.agentInput", }; +// Rows render in this order rather than in binding-definition order, so the shortcut someone opens +// this sheet to find sits at the top of its section. Every help id must appear here; the unit test +// fails when a new shortcut is added without a place, instead of silently sinking to the bottom. +export const SHORTCUT_HELP_ROW_ORDER: Record = { + general: [ + "toggle-command-center", + "search-files", + "show-shortcuts", + "toggle-settings", + "cycle-theme", + ], + workspaces: [ + "new-agent", + "new-workspace", + "workspace-jump-index", + "workspace-prev", + "workspace-next", + "pin-workspace", + "archive-workspace", + ], + "tabs-panes": [ + "workspace-tab-new", + "workspace-terminal-new", + "workspace-tab-close-current", + "workspace-tab-jump-index", + "workspace-tab-prev", + "workspace-tab-next", + "workspace-pane-split-right", + "workspace-pane-split-down", + "workspace-pane-focus-left", + "workspace-pane-focus-right", + "workspace-pane-focus-up", + "workspace-pane-focus-down", + "workspace-pane-move-tab-left", + "workspace-pane-move-tab-right", + "workspace-pane-move-tab-up", + "workspace-pane-move-tab-down", + "workspace-pane-close", + ], + layout: ["toggle-left-sidebar", "toggle-right-sidebar", "toggle-both-sidebars", "toggle-focus"], + "agent-input": [ + "focus-message-input", + "cycle-agent-mode", + "voice-toggle", + "dictation-toggle", + "agent-interrupt", + "voice-mute-toggle", + ], +}; + const SHORTCUT_HELP_LABEL_KEYS: Record = { "new-agent": "settings.shortcuts.help.openProject", "new-workspace": "settings.shortcuts.help.newWorkspace", @@ -165,6 +224,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record = { "workspace-pane-move-tab-down": "settings.shortcuts.help.moveTabDown", "workspace-pane-close": "settings.shortcuts.help.closePane", "workspace-terminal-new": "settings.shortcuts.help.newTerminal", + "search-files": "settings.shortcuts.help.searchFiles", "toggle-command-center": "settings.shortcuts.help.toggleCommandCenter", "show-shortcuts": "settings.shortcuts.help.showKeyboardShortcuts", "toggle-left-sidebar": "settings.shortcuts.help.toggleLeftSidebar", @@ -200,7 +260,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true }, help: { id: "new-agent", - section: "projects", + section: "workspaces", label: "Open project", }, }, @@ -211,7 +271,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, terminal: false }, help: { id: "new-agent", - section: "projects", + section: "workspaces", label: "Open project", }, }, @@ -224,7 +284,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "new-workspace", - section: "projects", + section: "workspaces", label: "New workspace", }, }, @@ -235,32 +295,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "new-workspace", - section: "projects", + section: "workspaces", label: "New workspace", }, }, - // --- Switch project (New Workspace screen) --- + // --- Search files (switch project on the New Workspace screen) --- { id: "workspace-project-pick-cmd-p-mac", - action: "workspace.project.pick", + action: "command-center.files", combo: "Cmd+P", when: { mac: true, commandCenter: false }, help: { - id: "switch-project", - section: "projects", - label: "Switch project", + id: "search-files", + section: "general", + label: "Search files", }, }, { id: "workspace-project-pick-ctrl-p-non-mac", - action: "workspace.project.pick", + action: "command-center.files", combo: "Ctrl+P", when: { mac: false, commandCenter: false, terminal: false }, help: { - id: "switch-project", - section: "projects", - label: "Switch project", + id: "search-files", + section: "general", + label: "Search files", }, }, @@ -274,7 +334,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "archive-workspace", - section: "projects", + section: "workspaces", label: "Archive workspace", }, }, @@ -287,7 +347,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "archive-workspace", - section: "projects", + section: "workspaces", label: "Archive workspace", }, }, @@ -300,7 +360,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "pin-workspace", - section: "projects", + section: "workspaces", label: "Pin chat", }, }, @@ -311,7 +371,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "pin-workspace", - section: "projects", + section: "workspaces", label: "Pin chat", }, }, @@ -382,7 +442,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "index" }, help: { id: "workspace-jump-index", - section: "navigation", + section: "workspaces", label: "Jump to workspace", defaultDisplayKeys: ["mod", "1-9"], }, @@ -395,7 +455,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "index" }, help: { id: "workspace-jump-index", - section: "navigation", + section: "workspaces", label: "Jump to workspace", defaultDisplayKeys: ["mod", "1-9"], }, @@ -408,7 +468,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "index" }, help: { id: "workspace-jump-index", - section: "navigation", + section: "workspaces", label: "Jump to workspace", defaultDisplayKeys: ["alt", "1-9"], }, @@ -423,7 +483,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "index" }, help: { id: "workspace-tab-jump-index", - section: "navigation", + section: "tabs-panes", label: "Jump to tab", defaultDisplayKeys: ["mod", "alt", "1-9"], }, @@ -436,7 +496,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "index" }, help: { id: "workspace-tab-jump-index", - section: "navigation", + section: "tabs-panes", label: "Jump to tab", defaultDisplayKeys: ["alt", "1-9"], }, @@ -449,7 +509,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "index" }, help: { id: "workspace-tab-jump-index", - section: "navigation", + section: "tabs-panes", label: "Jump to tab", defaultDisplayKeys: ["alt", "shift", "1-9"], }, @@ -464,7 +524,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: -1 }, help: { id: "workspace-prev", - section: "navigation", + section: "workspaces", label: "Previous workspace", }, }, @@ -476,7 +536,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: -1 }, help: { id: "workspace-prev", - section: "navigation", + section: "workspaces", label: "Previous workspace", }, }, @@ -488,7 +548,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: 1 }, help: { id: "workspace-next", - section: "navigation", + section: "workspaces", label: "Next workspace", }, }, @@ -500,7 +560,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: 1 }, help: { id: "workspace-next", - section: "navigation", + section: "workspaces", label: "Next workspace", }, }, @@ -512,7 +572,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: -1 }, help: { id: "workspace-prev", - section: "navigation", + section: "workspaces", label: "Previous workspace", }, }, @@ -524,7 +584,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: 1 }, help: { id: "workspace-next", - section: "navigation", + section: "workspaces", label: "Next workspace", }, }, @@ -538,7 +598,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: -1 }, help: { id: "workspace-tab-prev", - section: "navigation", + section: "tabs-panes", label: "Previous tab", }, }, @@ -550,7 +610,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: 1 }, help: { id: "workspace-tab-next", - section: "navigation", + section: "tabs-panes", label: "Next tab", }, }, @@ -686,7 +746,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "workspace-terminal-new", - section: "panels", + section: "tabs-panes", label: "New terminal", }, }, @@ -697,7 +757,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "workspace-terminal-new", - section: "panels", + section: "tabs-panes", label: "New terminal", }, }, @@ -710,7 +770,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true }, help: { id: "toggle-command-center", - section: "panels", + section: "general", label: "Toggle command center", }, }, @@ -721,7 +781,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, terminal: false }, help: { id: "toggle-command-center", - section: "panels", + section: "general", label: "Toggle command center", }, }, @@ -735,7 +795,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { focusScope: "other" }, help: { id: "show-shortcuts", - section: "panels", + section: "general", label: "Show keyboard shortcuts", defaultDisplayKeys: ["?"], note: "Available when focus is not in a text field or terminal.", @@ -750,7 +810,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true }, help: { id: "toggle-left-sidebar", - section: "panels", + section: "layout", label: "Toggle left sidebar", }, }, @@ -761,7 +821,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-left-sidebar", - section: "panels", + section: "layout", label: "Toggle left sidebar", }, }, @@ -772,7 +832,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "toggle-right-sidebar", - section: "panels", + section: "layout", label: "Toggle right sidebar", }, }, @@ -783,7 +843,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-right-sidebar", - section: "panels", + section: "layout", label: "Toggle right sidebar", }, }, @@ -802,7 +862,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "toggle-both-sidebars", - section: "panels", + section: "layout", label: "Toggle both sidebars", }, }, @@ -813,7 +873,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-both-sidebars", - section: "panels", + section: "layout", label: "Toggle both sidebars", }, }, @@ -826,7 +886,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "toggle-settings", - section: "panels", + section: "general", label: "Toggle settings", }, }, @@ -837,7 +897,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-settings", - section: "panels", + section: "general", label: "Toggle settings", }, }, @@ -850,7 +910,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "toggle-focus", - section: "panels", + section: "layout", label: "Toggle focus mode", }, }, @@ -861,7 +921,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-focus", - section: "panels", + section: "layout", label: "Toggle focus mode", }, }, @@ -874,7 +934,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "cycle-theme", - section: "panels", + section: "general", label: "Cycle theme", }, }, @@ -885,7 +945,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "cycle-theme", - section: "panels", + section: "general", label: "Cycle theme", }, }, @@ -1504,13 +1564,9 @@ export function buildKeyboardShortcutHelpSections( bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS, ): KeyboardShortcutHelpSection[] { const seenRows = new Set(); - const rowsBySection = new Map([ - ["navigation", []], - ["tabs-panes", []], - ["projects", []], - ["panels", []], - ["agent-input", []], - ]); + const rowsBySection = new Map( + SHORTCUT_HELP_SECTION_ORDER.map((sectionId) => [sectionId, []]), + ); for (const binding of bindings) { const help = binding.help; @@ -1540,19 +1596,17 @@ export function buildKeyboardShortcutHelpSections( }); } - const sectionOrder: ShortcutSectionId[] = [ - "navigation", - "tabs-panes", - "projects", - "panels", - "agent-input", - ]; - - return sectionOrder.flatMap((sectionId) => { + return SHORTCUT_HELP_SECTION_ORDER.flatMap((sectionId) => { const rows = rowsBySection.get(sectionId) ?? []; if (rows.length === 0) { return []; } + const order = SHORTCUT_HELP_ROW_ORDER[sectionId]; + const rank = (row: KeyboardShortcutHelpRow) => { + const index = order.indexOf(row.id); + return index === -1 ? order.length : index; + }; + rows.sort((left, right) => rank(left) - rank(right)); return [ { id: sectionId, diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts index 5ace809b9..494c2a188 100644 --- a/packages/app/src/keyboard/route-shortcut.test.ts +++ b/packages/app/src/keyboard/route-shortcut.test.ts @@ -351,6 +351,24 @@ describe("routeKeyboardShortcut — callbacks and pickers", () => { }); describe("routeKeyboardShortcut — toggle dialogs", () => { + it("opens the command center scoped to files from a workspace", () => { + expect( + routeKeyboardShortcut({ action: "command-center.files", payload: null }, makeCtx()), + ).toEqual({ kind: "command-center-toggle", nextOpen: true, scope: "files" }); + }); + + it("leaves the file-search shortcut to the project-picker host outside a workspace", () => { + expect( + routeKeyboardShortcut( + { action: "command-center.files", payload: null }, + makeCtx({ pathname: "/settings" }), + ), + ).toEqual({ + kind: "dispatch", + action: { id: "workspace.project.pick", scope: "workspace" }, + }); + }); + it("opens the command center when closed", () => { expect( routeKeyboardShortcut( diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index 71b4395cc..0fd622d30 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -32,7 +32,7 @@ export type ShortcutAction = | { kind: "router-push"; route: string } | { kind: "open-project-picker" } | { kind: "callback"; name: ShortcutCallbackName } - | { kind: "command-center-toggle"; nextOpen: boolean } + | { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" } | { kind: "shortcuts-dialog-toggle"; nextOpen: boolean }; const NONE: ShortcutAction = { kind: "none" }; @@ -198,6 +198,11 @@ export function routeKeyboardShortcut( return routeSettingsToggle(ctx); case "command-center.toggle": return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen }; + case "command-center.files": + if (parseHostWorkspaceRouteFromPathname(ctx.pathname)) { + return { kind: "command-center-toggle", nextOpen: true, scope: "files" }; + } + return dispatch({ id: "workspace.project.pick", scope: "workspace" }); case "shortcuts.dialog.toggle": return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen }; default: diff --git a/packages/app/src/keyboard/shortcut-help-search.test.ts b/packages/app/src/keyboard/shortcut-help-search.test.ts index c365a09b0..201d4865f 100644 --- a/packages/app/src/keyboard/shortcut-help-search.test.ts +++ b/packages/app/src/keyboard/shortcut-help-search.test.ts @@ -99,12 +99,12 @@ describe("filterShortcutHelpSections", () => { }); it("keeps every row of a section whose own title matches", () => { - const projects = filter("projects").find((section) => section.id === "projects"); - const allProjects = buildKeyboardShortcutHelpSections(MAC_DESKTOP).find( - (section) => section.id === "projects", + const matched = filter("layout").find((section) => section.id === "layout"); + const all = buildKeyboardShortcutHelpSections(MAC_DESKTOP).find( + (section) => section.id === "layout", ); - expect(projects?.rows).toHaveLength(allProjects?.rows.length ?? 0); + expect(matched?.rows).toHaveLength(all?.rows.length ?? 0); }); it("drops a section whose rows all miss", () => { diff --git a/packages/app/src/stores/keyboard-shortcuts-store.test.ts b/packages/app/src/stores/keyboard-shortcuts-store.test.ts index 17a67d767..f35b6ea18 100644 --- a/packages/app/src/stores/keyboard-shortcuts-store.test.ts +++ b/packages/app/src/stores/keyboard-shortcuts-store.test.ts @@ -4,6 +4,7 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store"; beforeEach(() => { useKeyboardShortcutsStore.setState({ commandCenterOpen: false, + commandCenterScope: null, shortcutsDialogOpen: false, capturingShortcut: false, altDown: false, @@ -19,6 +20,20 @@ describe("keyboard-shortcuts-store", () => { expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true); }); + it("opens the command center with a scope and clears it when closed", () => { + useKeyboardShortcutsStore.getState().setCommandCenterOpen(true, "files"); + expect(useKeyboardShortcutsStore.getState()).toMatchObject({ + commandCenterOpen: true, + commandCenterScope: "files", + }); + + useKeyboardShortcutsStore.getState().setCommandCenterOpen(false); + expect(useKeyboardShortcutsStore.getState()).toMatchObject({ + commandCenterOpen: false, + commandCenterScope: null, + }); + }); + it("toggles shortcut capture state", () => { expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(false); useKeyboardShortcutsStore.getState().setCapturingShortcut(true); diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts index f0ce7f960..0b0e2a516 100644 --- a/packages/app/src/stores/keyboard-shortcuts-store.ts +++ b/packages/app/src/stores/keyboard-shortcuts-store.ts @@ -3,8 +3,11 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts"; const SHORTCUT_BADGE_DELAY_MS = 150; +export type CommandCenterScope = "files" | null; + interface KeyboardShortcutsState { commandCenterOpen: boolean; + commandCenterScope: CommandCenterScope; shortcutsDialogOpen: boolean; capturingShortcut: boolean; altDown: boolean; @@ -13,7 +16,8 @@ interface KeyboardShortcutsState { /** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */ sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[]; - setCommandCenterOpen: (open: boolean) => void; + setCommandCenterOpen: (open: boolean, scope?: CommandCenterScope) => void; + setCommandCenterScope: (scope: CommandCenterScope) => void; setShortcutsDialogOpen: (open: boolean) => void; setCapturingShortcut: (capturing: boolean) => void; setAltDown: (down: boolean) => void; @@ -47,6 +51,7 @@ function updateBadgeTimer( export const useKeyboardShortcutsStore = create((set, get) => ({ commandCenterOpen: false, + commandCenterScope: null, shortcutsDialogOpen: false, capturingShortcut: false, altDown: false, @@ -54,7 +59,9 @@ export const useKeyboardShortcutsStore = create((set, ge showShortcutBadges: false, sidebarShortcutWorkspaceTargets: [], - setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), + setCommandCenterOpen: (open, scope = null) => + set({ commandCenterOpen: open, commandCenterScope: open ? scope : null }), + setCommandCenterScope: (scope) => set({ commandCenterScope: scope }), setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }), setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }), setAltDown: (down) => {