Search workspace files from the command center (#3059)

* feat(command-center): add workspace file search

Use the existing workspace-aware fuzzy search so file lookup follows the file explorer's ignore and hidden-directory rules. Cmd/Ctrl+P opens a removable Files scope while the normal command center also includes file matches.

* fix(command-center): stabilize file search results

* fix(shortcuts): preserve project picker command

* fix(command-center): keep file results while searching

* feat(command-center): integrate the files scope into the search row

The scope was a 24px pill with 12px text sitting inside the 16px input line, so
it read as a badge dropped into the query rather than the scope of the search.

Make it a leading cell of the search row instead: folder icon, input-sized label,
and a hairline divider before the caret. It stretches to the row's cross size and
bleeds through the header padding with negative margins, so its height is driven
entirely by the input and the panel's own radius clips its corner. Toggling the
scope cannot resize the header, which the new spec pins.

* fix(command-center): restore the files scope as a chip

The previous commit turned the scope into a flush leading cell of the header. Put
the chip back: rounded, bordered, folder icon and 14px label, no divider.

It stretches to the row instead of carrying a height, so it is exactly as tall as
the input's line box and cannot resize the header.

* refactor(shortcuts): regroup and reorder the shortcut help sections

Panels was a junk drawer: the command center, file search, the shortcuts sheet
itself, new terminal, the sidebars, settings, focus mode and theme cycling. The
two shortcuts people reach for most, Cmd+K and Cmd+P, sat inside it below three
other sections. Navigation held both workspace and tab movement.

Sections are now General, Projects & Workspaces, Tabs & Panes, Layout, and Agent
Input. General leads with the command center and file search. Tab movement joins
Tabs & Panes, new terminal joins the tabs it creates, and Layout keeps only the
sidebar and focus toggles.

Row order is now data rather than a side effect of binding-definition order, so
the row someone opens the sheet to find sits at the top of its section. A test
fails when a new shortcut has no place in that order instead of letting it sink
to the bottom unnoticed.

* Keep file search failures in view
This commit is contained in:
Mohamed Boudra
2026-08-10 17:02:35 +02:00
committed by GitHub
parent 42f1d0b289
commit a98d154b6b
26 changed files with 1070 additions and 141 deletions
@@ -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<SeededWorkspace[]> {
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()));
}
});
@@ -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<string, SurfaceRect[]>;
}
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<string, unknown> | 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<string, unknown>)
: null;
} catch {
return null;
}
}
export async function delayDirectorySuggestionResponses(page: Page, delayMs = 400): Promise<void> {
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<void> {
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<void> {
await page.evaluate((selectors) => {
const oracleWindow = window as LayoutOracleWindow;
const observation: LayoutObservation = { active: true, layoutShift: 0, samples: {} };
oracleWindow.__commandCenterLayoutObservation = observation;
const observedElements = new WeakSet<Element>();
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<HTMLElement>(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<void> {
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<void> {
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");
}
@@ -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<string, number>;
offsets: readonly number[];
inputRef: React.RefObject<TextInput | null>;
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<TextInput>(null);
const previousOpenRef = useRef(open);
const [query, setQuery] = useState("");
const [activeId, setActiveId] = useState<string | null>(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<CommandCenterResultSection[]>(() => {
const results = fileSearchEntries.map<CommandCenterFileResult>((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
}
>
<ResultContent result={result} />
</Pressable>
@@ -337,6 +394,28 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
});
function ResultContent({ result }: { result: CommandCenterResult }) {
if (result.kind === "file") {
return (
<View style={styles.rowContent}>
<View style={styles.rowMain}>
<View style={styles.iconSlot} testID="command-center-file-icon">
<MaterialFileIcon fileName={result.title} size={16} />
</View>
<Text style={styles.fileLine} numberOfLines={1} testID="command-center-file-line">
<Text style={styles.fileName} testID="command-center-file-name">
{result.title}
</Text>
{result.subtitle ? (
<Text style={styles.filePath} testID="command-center-file-path">
{" "}
{result.subtitle}
</Text>
) : null}
</Text>
</View>
</View>
);
}
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(
() => <Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>,
[t],
() =>
state.fileSearchError || state.fileSearchLoading ? null : (
<Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>
),
[state.fileSearchError, state.fileSearchLoading, t],
);
const fileSearchError = useMemo(
() =>
state.fileSearchError ? (
<Text
accessibilityLiveRegion="polite"
style={styles.errorText}
testID="command-center-file-search-error"
>
{t("common.errors.error")}: {state.fileSearchError}
</Text>
) : 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}
>
<View style={styles.bottomSheetHeader}>
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
@@ -614,13 +714,22 @@ export function CommandCenter() {
onChangeText={state.setQuery}
onKeyPress={keyPress}
onSubmitEditing={submit}
placeholder={t("shell.commandCenter.placeholder")}
style={styles.input}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</IsolatedBottomSheetModal>
);
@@ -632,20 +741,32 @@ export function CommandCenter() {
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
<View style={styles.header}>
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
value={state.query}
onChangeText={state.setQuery}
placeholder={t("shell.commandCenter.placeholder")}
style={styles.input}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
{fileSearchError}
<FlatList ref={listRef} {...commonListProps} />
</View>
</View>
</Modal>
@@ -653,6 +774,41 @@ export function CommandCenter() {
);
}
function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; label: string }) {
return (
<View style={styles.fileSearchStatus} testID="command-center-file-search-status">
{loading ? (
<View
accessibilityLabel={label}
accessibilityRole="progressbar"
testID="command-center-file-search-loading"
>
<ThemedLoadingSpinner size={14} />
</View>
) : null}
</View>
);
}
// 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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onRemove}
style={styles.scopeChip}
testID="command-center-files-scope"
>
<ThemedFolder size={14} strokeWidth={2.2} />
<Text style={styles.scopeChipLabel}>{label}</Text>
<ThemedX size={12} strokeWidth={2.2} />
</Pressable>
);
}
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" },
@@ -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;
}
@@ -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) : "",
};
}
@@ -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: "",
});
});
});
@@ -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<WorkspaceFileSearchState>(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<void> {
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,
};
}
@@ -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":
+5 -1
View File
@@ -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");
+8 -4
View File
@@ -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: "تبديل الشريط الجانبي الأيسر",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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: "左サイドバーを切り替え",
+8 -4
View File
@@ -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: "왼쪽 사이드바 토글",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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: "Переключить левую боковую панель",
+8 -4
View File
@@ -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: "切换左侧边栏",
+1
View File
@@ -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"
@@ -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 });
+125 -71
View File
@@ -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<ShortcutSectionId, string> = {
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<ShortcutSectionId, string> = {
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<ShortcutSectionId, readonly string[]> = {
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<string, string> = {
"new-agent": "settings.shortcuts.help.openProject",
"new-workspace": "settings.shortcuts.help.newWorkspace",
@@ -165,6 +224,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"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<string>();
const rowsBySection = new Map<ShortcutSectionId, KeyboardShortcutHelpRow[]>([
["navigation", []],
["tabs-panes", []],
["projects", []],
["panels", []],
["agent-input", []],
]);
const rowsBySection = new Map<ShortcutSectionId, KeyboardShortcutHelpRow[]>(
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,
@@ -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<ShortcutAction>({ 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<ShortcutAction>({
kind: "dispatch",
action: { id: "workspace.project.pick", scope: "workspace" },
});
});
it("opens the command center when closed", () => {
expect(
routeKeyboardShortcut(
+6 -1
View File
@@ -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:
@@ -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", () => {
@@ -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);
@@ -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<KeyboardShortcutsState>((set, get) => ({
commandCenterOpen: false,
commandCenterScope: null,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
@@ -54,7 +59,9 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((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) => {