From 6db8bd124fba8b9b8f227c1fe3ea6081319a422c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 10 Sep 2026 10:13:15 +0200 Subject: [PATCH] fix(app): put content search in the real Command Center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The content scope replaced the whole panel with a lookalike shell: its own query field pinned to the bottom, its own scope button, and a permanent strip naming the implementation ("Saved files up to 1 MiB · Ignore case"). Search now uses the Command Center's own header, so every scope shares one input, one chip, one loading slot and one key dispatcher. Content search contributes only a body: results left, source preview right, in a wider panel. The strip is gone. Idle shows nothing, an empty result set reuses the shared "No matches", and loading and errors are the existing spinner and Retry. Two row defects fell out of the wider layout. A deep path truncated its own file name away, so a row could not be identified; the name and coordinates now lead and the directory is what truncation eats. A match far into a long line sat outside the row entirely; the run-up is trimmed when there is enough of it to be worth trimming. The preview centres such a match horizontally instead of leaving it clipped at the scroller edge. Pane Find required pane context, which an overlay preview does not have, so rendering a source view inside the Command Center threw. Find now belongs to panes only and stands down elsewhere, and the file pane routes through the source module instead of rebuilding its theme. --- .../command-center-file-search.spec.ts | 4 +- .../helpers/command-center-file-search.ts | 2 +- .../helpers/workspace-content-search.ts | 3 +- .../app/src/command-center/command-center.tsx | 137 +++---- .../workspace-content-search/index.tsx | 344 +++++++++--------- .../internal/model.test.ts | 31 ++ .../internal/model.ts | 20 +- packages/app/src/file-pane/find/index.web.tsx | 12 +- packages/app/src/file-pane/pane.tsx | 23 +- .../app/src/file-pane/source/view.web.tsx | 3 +- packages/app/src/i18n/resources/ar.ts | 6 +- packages/app/src/i18n/resources/en.ts | 6 +- packages/app/src/i18n/resources/es.ts | 6 +- packages/app/src/i18n/resources/fr.ts | 6 +- packages/app/src/i18n/resources/ja.ts | 6 +- packages/app/src/i18n/resources/ko.ts | 6 +- packages/app/src/i18n/resources/pt-BR.ts | 6 +- packages/app/src/i18n/resources/ru.ts | 6 +- packages/app/src/i18n/resources/zh-CN.ts | 6 +- packages/app/src/panels/pane-context.tsx | 7 +- 20 files changed, 318 insertions(+), 322 deletions(-) diff --git a/packages/app/e2e/browser/command-center-file-search.spec.ts b/packages/app/e2e/browser/command-center-file-search.spec.ts index 5964dcc978..be0f48ec3c 100644 --- a/packages/app/e2e/browser/command-center-file-search.spec.ts +++ b/packages/app/e2e/browser/command-center-file-search.spec.ts @@ -102,8 +102,8 @@ test("dropping the files scope leaves the search row the same height", async ({ 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); + await page.getByTestId("command-center-scope").click(); + await expect(page.getByTestId("command-center-scope")).toHaveCount(0); const unscoped = await header.boundingBox(); expect(scoped?.height).toBe(unscoped?.height); diff --git a/packages/app/e2e/support/helpers/command-center-file-search.ts b/packages/app/e2e/support/helpers/command-center-file-search.ts index 781470b582..69df754e82 100644 --- a/packages/app/e2e/support/helpers/command-center-file-search.ts +++ b/packages/app/e2e/support/helpers/command-center-file-search.ts @@ -26,7 +26,7 @@ const SURFACE_SELECTORS = { 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"]', + scope: '[data-testid="command-center-scope"]', fileRow: '[data-testid^="command-center-file-row-"]', fileIcon: '[data-testid="command-center-file-icon"]', fileLine: '[data-testid="command-center-file-line"]', diff --git a/packages/app/e2e/support/helpers/workspace-content-search.ts b/packages/app/e2e/support/helpers/workspace-content-search.ts index 11d41c1ddb..315ee60d0f 100644 --- a/packages/app/e2e/support/helpers/workspace-content-search.ts +++ b/packages/app/e2e/support/helpers/workspace-content-search.ts @@ -28,9 +28,10 @@ export async function previewAndOpenSecondOccurrence(page: Page, screenshot: str await page.keyboard.press("ArrowDown"); await expect(panel.getByTestId("file-source-editor")).toContainText("const emoji"); await expect(input).toBeFocused(); + // The query field is the Command Center's own header input, above the results and preview. const inputBox = await input.boundingBox(); const previewBox = await panel.getByTestId("file-source-editor").boundingBox(); - expect(inputBox!.y).toBeGreaterThan(previewBox!.y + previewBox!.height); + expect(inputBox!.y + inputBox!.height).toBeLessThan(previewBox!.y); await panel.screenshot({ path: screenshot }); await page.keyboard.press("Enter"); await expect(panel).toBeHidden(); diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 494f66ff58..f45117c26c 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -91,6 +91,16 @@ const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMut const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({ color: theme.colors.foregroundMuted, })); +const SCOPE_LABELS = { + files: { + chip: "shell.commandCenter.files", + placeholder: "shell.commandCenter.filePlaceholder", + }, + content: { + chip: "shell.commandCenter.content", + placeholder: "shell.commandCenter.contentPlaceholder", + }, +} as const; const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"]; const CONTENT_SEARCH_SNAP_POINTS = ["90%"]; const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const; @@ -703,22 +713,30 @@ export function CommandCenter() { onScroll: handleListScroll, scrollEventThrottle: 16, }; - const keyPress = useCallback( - ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key), + // Every scope wears the same header; only the chip and the placeholder name it. + const scopeLabels = state.scope ? SCOPE_LABELS[state.scope] : null; + const scopeChipLabel = scopeLabels ? t(scopeLabels.chip) : null; + const placeholder = t(scopeLabels?.placeholder ?? "shell.commandCenter.placeholder"); + // Content search owns arrows, Enter and Backspace while its scope is active; every other scope + // is driven by the shared result list. One dispatcher so the input, submit and the web overlay + // can never disagree about who is steering. + const dispatchKey = useCallback( + (key: string) => + state.scope === "content" ? (contentKeyHandler.current?.(key) ?? false) : state.key(key), [state], ); - const submit = useCallback(() => state.key("Enter"), [state]); + const keyPress = useCallback( + ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => dispatchKey(key), + [dispatchKey], + ); + const submit = useCallback(() => dispatchKey("Enter"), [dispatchKey]); const handleWebOverlayKeyDown = useCallback( (event: KeyboardEvent) => { - const handled = - state.scope === "content" - ? (contentKeyHandler.current?.(event.key) ?? false) - : state.key(event.key); - if (!handled) return false; + if (!dispatchKey(event.key)) return false; event.preventDefault(); return true; }, - [state], + [dispatchKey], ); const setWebOverlayScope = useWebOverlayRegistration({ active: isWeb && state.open && !showBottomSheet, @@ -752,46 +770,37 @@ export function CommandCenter() { keyboardBlurBehavior="restore" accessible={false} > + + {scopeChipLabel ? : null} + + + {state.scope === "content" ? ( ) : ( <> - - {state.scope === "files" ? ( - - ) : null} - - - {fileSearchError} @@ -810,40 +819,36 @@ export function CommandCenter() { testID="command-center-panel" style={[styles.panel, state.scope === "content" && styles.contentPanel]} > + + {scopeChipLabel ? ( + + ) : null} + + + {state.scope === "content" ? ( ) : ( <> - - {state.scope === "files" ? ( - - ) : null} - - - {fileSearchError} @@ -881,7 +886,7 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) { accessibilityLabel={label} onPress={onRemove} style={styles.scopeChip} - testID="command-center-files-scope" + testID="command-center-scope" > {label} diff --git a/packages/app/src/command-center/workspace-content-search/index.tsx b/packages/app/src/command-center/workspace-content-search/index.tsx index 60663a8ea0..4095c73491 100644 --- a/packages/app/src/command-center/workspace-content-search/index.tsx +++ b/packages/app/src/command-center/workspace-content-search/index.tsx @@ -1,17 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { - FlatList, - Pressable, - Text, - View, - type NativeSyntheticEvent, - type TextInputKeyPressEventData, -} from "react-native"; +import { FlatList, Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; +import type { WorkspaceContentMatch } from "@getpaseo/protocol/messages"; import { isWeb } from "@/constants/platform"; import { Button } from "@/components/ui/button"; -import { EditingTextInput } from "@/components/ui/text-input"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; @@ -22,21 +15,26 @@ import { createWorkspaceFileTabTarget } from "@/workspace/file-open"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { WorkspaceFileSource } from "@/file-pane/source"; -import { WorkspaceContentSearchModel, type ContentSearchTransport } from "./internal/model"; +import { describeWorkspaceFilePath } from "../workspace-file-search-model"; +import { + WorkspaceContentSearchModel, + type ContentSearchSnapshot, + type ContentSearchTransport, +} from "./internal/model"; const ThemedSpinner = withUnistyles(LoadingSpinner, (theme) => ({ color: theme.colors.foregroundMuted, })); -import type { WorkspaceContentMatch } from "@getpaseo/protocol/messages"; -import type { ContentSearchSnapshot } from "./internal/model"; interface Props { + /** The Command Center owns the field; this body only reacts to what was typed in it. */ + query: string; compact: boolean; - bottomSheet: boolean; close(): void; clearScope(): void; keyHandler: React.RefObject<((key: string) => boolean) | null>; } + export function WorkspaceContentSearch(props: Props) { const { keyHandler, close, clearScope } = props; const { t } = useTranslation(); @@ -78,13 +76,14 @@ export function WorkspaceContentSearch(props: Props) { /> ); } + function ContentSearch({ transport, cwd, serverId, workspaceId, + query, compact, - bottomSheet, close, clearScope, keyHandler, @@ -94,7 +93,6 @@ function ContentSearch({ serverId: string; workspaceId: string; }) { - const { t } = useTranslation(); const [previewPage, setPreviewPage] = useState(false); const closeRef = useRef(close); closeRef.current = close; @@ -121,6 +119,12 @@ function ContentSearch({ [cwd, transport, serverId, workspaceId], ); useEffect(() => () => model.dispose(), [model]); + // The field lives in the Command Center header, so the typed value is a prop; this is the only + // bridge from it into the model that owns the search. + useEffect(() => { + setPreviewPage(false); + model.setQuery(query); + }, [model, query]); const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); const listRef = useRef(null); useEffect(() => { @@ -159,19 +163,6 @@ function ContentSearch({ if (keyHandler.current === key) keyHandler.current = null; }; }, [keyHandler, key]); - const onKey = useCallback( - ({ nativeEvent }: NativeSyntheticEvent) => { - if (bottomSheet) key(nativeEvent.key); - }, - [bottomSheet, key], - ); - const changeQuery = useCallback( - (value: string) => { - setPreviewPage(false); - model.setQuery(value); - }, - [model], - ); const select = useCallback( (index: number) => { model.select(index); @@ -186,54 +177,34 @@ function ContentSearch({ ), [select, state.activeIndex], ); - const empty = useMemo(() => { - let text = ""; - if (state.status === "idle") text = t("shell.commandCenter.contentEmpty"); - if (state.status === "ready") text = t("shell.commandCenter.noMatches"); - return {text}; - }, [state.status, t]); + const emptyState = useMemo( + () => , + [model.retry, state], + ); + const limitNotice = useMemo(() => , [state.limited]); return ( - - - {!compact || !previewPage ? ( - - - - ) : null} - {!compact || previewPage ? ( - - ) : null} - - - - - - + + {!compact || !previewPage ? ( + + + + ) : null} + {!compact || previewPage ? ( + + ) : null} ); } + function ResultRow({ item, index, @@ -247,6 +218,18 @@ function ResultRow({ }) { const onPress = useCallback(() => select(index), [index, select]); const accessibilityState = useMemo(() => ({ selected }), [selected]); + // Same hover/selection idiom as the Command Center's own result rows, one tint apart so the + // keyboard cursor that drives the preview stays distinguishable from the pointer. + const style = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.row, + (Boolean(hovered) || pressed) && styles.hovered, + selected && styles.selected, + ], + [selected], + ); + const line = useMemo(() => describeMatchLine(item), [item]); + const file = useMemo(() => describeWorkspaceFilePath(item.path), [item.path]); return ( - - {item.path}:{item.line}:{item.columnStart} - - {item.snippet.slice(0, item.snippetMatchStart)} - - {item.snippet.slice(item.snippetMatchStart, item.snippetMatchEnd)} + {line.before} + {line.match} + {line.after} + + + + {file.name}:{item.line}:{item.columnStart} - {item.snippet.slice(item.snippetMatchEnd)} + {file.directory ? {file.directory} : null} ); } + +/** The row is one line wide, so the match has to survive indentation and long-line offsets. */ +function describeMatchLine(item: WorkspaceContentMatch) { + // Only a run-up long enough to be worth cutting is cut, so an ordinary line keeps its start. + const cut = item.snippetMatchStart > SNIPPET_LEAD * 2; + const from = cut ? item.snippetMatchStart - SNIPPET_LEAD : 0; + const before = item.snippet.slice(from, item.snippetMatchStart); + return { + before: cut ? `…${before}` : before.trimStart(), + match: item.snippet.slice(item.snippetMatchStart, item.snippetMatchEnd), + after: item.snippet.slice(item.snippetMatchEnd), + }; +} function resultKey(item: WorkspaceContentMatch) { return `${item.path}:${item.line}:${item.columnStart}`; } function resultLayout(_data: ArrayLike | null | undefined, index: number) { - return { length: 56, offset: index * 56, index }; + return { length: RESULT_ROW_HEIGHT, offset: index * RESULT_ROW_HEIGHT, index }; } + +/** Idle stays blank: the header placeholder already says what the field does. */ +function SearchState({ state, retry }: { state: ContentSearchSnapshot; retry(): void }) { + const { t } = useTranslation(); + if (state.status === "searching") + return ( + + + {t("shell.commandCenter.searchingFiles")} + + ); + if (state.status === "error") + return ( + + {state.message} + + + ); + if (state.status === "ready") + return {t("shell.commandCenter.noMatches")}; + return null; +} + +function ResultLimit({ limited }: { limited: boolean }) { + const { t } = useTranslation(); + if (!limited) return null; + return {t("shell.commandCenter.contentLimited")}; +} + function ContentPreview({ state, model, @@ -304,25 +332,33 @@ function ContentPreview({ ); return ( - - {compact ? ( - + ) : null} + + {match.path} + + - ) : null} - - {match?.path ?? t("shell.commandCenter.contentPreview")} - - - + + ) : null} {preview.status === "loading" ? ( - {t("shell.commandCenter.contentLoadingPreview")} + + + ) : null} {preview.status === "error" ? ( - - {preview.message} + + {preview.message} @@ -340,102 +376,76 @@ function ContentPreview({ ); } -function SearchStatus({ state, retry }: { state: ContentSearchSnapshot; retry(): void }) { - const { t } = useTranslation(); - return ( - - {state.status === "searching" ? ( - <> - - {t("shell.commandCenter.searchingFiles")} - - ) : null} - {state.status === "error" ? ( - <> - {state.message} - - - ) : null} - {state.limited ? ( - {t("shell.commandCenter.contentLimited")} - ) : null} - {state.status === "idle" || (state.status === "ready" && !state.limited) ? ( - - {t("shell.commandCenter.contentScope")} - - ) : null} - - ); -} + +// Matches the Command Center's own two-line result row. +const RESULT_ROW_HEIGHT = 56; +// Characters of the matched line kept ahead of the match itself. +const SNIPPET_LEAD = 24; + const styles = StyleSheet.create((theme) => ({ - root: { flex: 1, minHeight: 0 }, body: { flex: 1, minHeight: 0, flexDirection: "row" }, + // A result row is two truncated single lines, so extra panel width buys the list nothing and + // buys the code preview everything: the column is a fixed reading width, capped so a narrow + // desktop panel cannot starve the preview. results: { - width: "40%", + width: 360, + maxWidth: "45%", minHeight: 0, borderRightWidth: 1, borderRightColor: theme.colors.border, }, - full: { width: "100%", borderRightWidth: 0 }, + full: { width: "100%", maxWidth: "100%", borderRightWidth: 0 }, row: { - height: 56, - paddingHorizontal: theme.spacing[3], + height: RESULT_ROW_HEIGHT, + justifyContent: "center", + paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2], - gap: theme.spacing[1], }, + hovered: { backgroundColor: theme.colors.surface1 }, selected: { backgroundColor: theme.colors.surface2 }, - path: { color: theme.colors.foreground, fontSize: theme.fontSize.sm }, + // The matched line leads, in the code type scale; the location is its subtitle. snippet: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + fontSize: theme.fontSize.code, + lineHeight: 18, fontFamily: theme.fontFamily.mono, }, - match: { - color: theme.colors.foreground, - backgroundColor: theme.colors.terminal.selectionBackground, - }, + match: { backgroundColor: theme.colors.terminal.selectionBackground }, + // The file name and coordinates lead so that truncation eats the directory, not the identity. + location: { fontSize: theme.fontSize.sm, lineHeight: 16 }, + locationFile: { color: theme.colors.foreground }, + locationDirectory: { color: theme.colors.foregroundMuted }, preview: { flex: 1, minWidth: 0, minHeight: 0 }, previewHeader: { flexDirection: "row", alignItems: "center", - padding: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], gap: theme.spacing[2], borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, previewPath: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, status: { - padding: theme.spacing[4], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[6], + }, + muted: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted }, + error: { color: theme.colors.statusDanger }, + emptyText: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[6], + textAlign: "center", + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, + limit: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - statusRow: { - minHeight: 40, - paddingHorizontal: theme.spacing[3], - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - hint: { flex: 1, fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted }, - error: { color: theme.colors.statusDanger }, - query: { - flexDirection: "row", - alignItems: "center", - borderTopWidth: 1, - borderTopColor: theme.colors.border, - padding: theme.spacing[2], - gap: theme.spacing[2], - }, - input: { - flex: 1, - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - ...(isWeb ? { outlineWidth: 0 } : {}), - }, })); diff --git a/packages/app/src/command-center/workspace-content-search/internal/model.test.ts b/packages/app/src/command-center/workspace-content-search/internal/model.test.ts index 12d1986587..6fc9887850 100644 --- a/packages/app/src/command-center/workspace-content-search/internal/model.test.ts +++ b/packages/app/src/command-center/workspace-content-search/internal/model.test.ts @@ -201,3 +201,34 @@ test("textual image occurrences expose saved source without changing the read ki model.dispose(); } }); + +test("revisiting a file re-reads it, so a file deleted since the search stops previewing", async () => { + const reads: string[] = []; + let deleted = false; + const model = new WorkspaceContentSearchModel({ + cwd: "/workspace", + debounceMs: 0, + open() {}, + transport: { + searchWorkspaceContent: async () => result([match("a.ts"), match("b.ts")]), + readFile: async (_cwd, path) => { + reads.push(path); + if (path === "a.ts" && deleted) throw new Error("ENOENT: no such file"); + return file(path, `NEEDLE in ${path}`); + }, + }, + }); + try { + model.setQuery("needle"); + await expect.poll(() => model.getSnapshot().preview.status).toBe("ready"); + expect(reads).toEqual(["a.ts"]); + model.move(1); + await expect.poll(() => reads).toEqual(["a.ts", "b.ts"]); + deleted = true; + model.move(-1); + await expect.poll(() => model.getSnapshot().preview).toMatchObject({ status: "error" }); + expect(reads).toEqual(["a.ts", "b.ts", "a.ts"]); + } finally { + model.dispose(); + } +}); diff --git a/packages/app/src/command-center/workspace-content-search/internal/model.ts b/packages/app/src/command-center/workspace-content-search/internal/model.ts index 3725d7db51..32eeeac1dd 100644 --- a/packages/app/src/command-center/workspace-content-search/internal/model.ts +++ b/packages/app/src/command-center/workspace-content-search/internal/model.ts @@ -32,6 +32,8 @@ export interface ContentSearchSnapshot { preview: Preview; } +/** The daemon refuses to read more, and rg refuses to search a file bigger than this. */ +const MAX_PREVIEW_BYTES = 1_048_576; export class WorkspaceContentSearchModel { private snapshot: ContentSearchSnapshot = { query: "", @@ -122,13 +124,11 @@ export class WorkspaceContentSearchModel { private async preview() { const match = this.snapshot.matches[this.snapshot.activeIndex]; if (!match) return; + // Only the file on screen is retained. Re-reading on every arrival is what lets the preview + // tell the truth about a file that changed or disappeared since the search ran; see the + // deleted-file recovery case in the browser spec before trading it for fewer reads. if (this.file?.path === match.path) { - this.update({ - preview: { - status: "ready", - ...this.file, - }, - }); + this.update({ preview: { status: "ready", ...this.file } }); return; } this.update({ preview: { status: "loading" } }); @@ -141,17 +141,13 @@ export class WorkspaceContentSearchModel { this.options.cwd, path, undefined, - 1_048_576, + MAX_PREVIEW_BYTES, ); if (this.disposed) return; if (query === this.controller) { const content = explorerFileFromReadResult(file).content; if (content === undefined) throw new Error("This file is no longer text"); - this.file = { - path, - content, - size: file.size, - }; + this.file = { path, content, size: file.size }; } } catch (error) { if ( diff --git a/packages/app/src/file-pane/find/index.web.tsx b/packages/app/src/file-pane/find/index.web.tsx index 7136542415..ef3e53dcc6 100644 --- a/packages/app/src/file-pane/find/index.web.tsx +++ b/packages/app/src/file-pane/find/index.web.tsx @@ -11,7 +11,7 @@ import { View, type View as ViewInstance } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { PaneFind, type PaneFindHandle } from "@/pane-find"; -import { usePaneFocus } from "@/panels/pane-context"; +import { usePaneFocusIfAny } from "@/panels/pane-context"; import { hasActiveWebOverlay } from "@/lib/overlay-root"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; @@ -29,7 +29,9 @@ export function FileFind({ const { t } = useTranslation(); const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); const widget = useRef(null); - const { isInteractive } = usePaneFocus(); + // A source view can also render outside any pane, such as the Command Center content + // preview. Pane Find belongs to the pane, so there is nothing to open there. + const pane = usePaneFocusIfAny(); const active = useRetainedPanelActive(); // RN Web hands the underlying DOM element to a View ref; the model measures it. const setWidgetNode = useCallback( @@ -37,7 +39,7 @@ export function FileFind({ [model], ); useEffect(() => { - if (!isInteractive || !active) return; + if (!pane?.isInteractive || !active) return; function onKeyDown(event: KeyboardEvent) { if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event)) return; @@ -54,7 +56,7 @@ export function FileFind({ } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); - }, [active, editor, isInteractive, model]); + }, [active, editor, model, pane?.isInteractive]); const replace = useMemo( () => @@ -68,7 +70,7 @@ export function FileFind({ }, [model, state.readOnly, state.replacement], ); - if (!state.open) return null; + if (!pane || !state.open) return null; const total = `${state.total}${state.limited ? "+" : ""}`; let status = ""; if (state.query) { diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 557482d868..3e88e396b8 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -33,7 +33,7 @@ import { FileMarkdownPreview } from "./markdown-preview"; import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model"; import { createFileObservationSource } from "./editor/observation-source"; import { FileEditorView } from "./editor/view"; -import { FileSourceView } from "./source/view"; +import { WorkspaceFileSource } from "./source"; import type { FileConflictAlertState } from "./conflict-alert"; import type { LiveFileModel } from "./live-file/model"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -79,32 +79,13 @@ function ReadonlySource({ location: WorkspaceFileLocation; navigationRevision: number; }) { - const theme = UnistylesRuntime.getTheme(); - const { t } = useTranslation(); - const visualTheme = useMemo( - () => ({ - colorScheme: theme.colorScheme, - background: theme.colors.surface0, - foreground: theme.colors.foreground, - cursor: theme.colors.terminal.cursor, - foregroundMuted: theme.colors.foregroundMuted, - border: theme.colors.border, - selection: theme.colors.terminal.selectionBackground, - monoFont: theme.fontFamily.mono, - codeFontSize: theme.fontSize.code, - syntax: theme.colors.syntax, - }), - [theme], - ); return ( - ); } diff --git a/packages/app/src/file-pane/source/view.web.tsx b/packages/app/src/file-pane/source/view.web.tsx index bc2c346046..2d895a9e0c 100644 --- a/packages/app/src/file-pane/source/view.web.tsx +++ b/packages/app/src/file-pane/source/view.web.tsx @@ -120,7 +120,8 @@ function ReadonlyCodeMirror({ const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location); view.dispatch({ selection: { anchor: from, head: to }, - effects: EditorView.scrollIntoView(from, { y: "center" }), + // A match far into a long line lands at the scroller edge without the horizontal centre. + effects: EditorView.scrollIntoView(from, { x: "center", y: "center" }), }); }, [content, location, navigationRevision]); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b1eddfaa74..7322587d03 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -74,18 +74,14 @@ export const ar: TranslationResources = { }, commandCenter: { contentTitle: "البحث في محتوى الملفات", + content: "محتوى الملفات", contentPlaceholder: "البحث في الملفات المحفوظة...", contentUnavailable: "مساحة العمل غير متاحة", contentUpdateHost: "حدّث هذا المضيف للبحث في محتوى الملفات", - contentEmpty: "البحث في الملفات المحفوظة", contentBack: "العودة إلى النتائج", - contentPreview: "معاينة", contentOpen: "فتح", contentLoadingPreview: "جارٍ تحميل المعاينة...", contentLimited: "النتائج محدودة — حدّد البحث أكثر", - contentScopeDetail: - "يبحث في ملفات UTF-8 المحفوظة، بما فيها المخفية وغير المتتبعة. تُطبق قواعد التجاهل. لا يشمل الروابط الرمزية والتعديلات غير المحفوظة.", - contentScope: "ملفات محفوظة حتى 1 MiB · تجاهل حالة الأحرف", contentChanged: "تغيّر الملف — لم تعد المطابقة في هذا الموضع", placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 407ae0bf1d..519569a4a3 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -71,18 +71,14 @@ export const en = { }, commandCenter: { contentTitle: "Search file contents", + content: "File contents", contentPlaceholder: "Search saved file contents...", contentUnavailable: "Workspace unavailable", contentUpdateHost: "Update this host to search file contents", - contentEmpty: "Search saved file contents", contentBack: "Back to results", - contentPreview: "Preview", contentOpen: "Open", contentLoadingPreview: "Loading preview...", contentLimited: "Results limited — refine your search", - contentScopeDetail: - "Searches saved UTF-8 files, including hidden and untracked files. Ignore rules apply. Symbolic links and unsaved edits are excluded.", - contentScope: "Saved files up to 1 MiB · Ignore case", contentChanged: "File changed — saved match is no longer at this location", placeholder: "Search commands, files, workspaces, and agents...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 8e156344ce..1cf69aa451 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -74,18 +74,14 @@ export const es: TranslationResources = { }, commandCenter: { contentTitle: "Buscar contenido de archivos", + content: "Contenido de archivos", contentPlaceholder: "Buscar contenido guardado...", contentUnavailable: "Espacio de trabajo no disponible", contentUpdateHost: "Actualiza este host para buscar contenido", - contentEmpty: "Buscar contenido guardado", contentBack: "Volver a resultados", - contentPreview: "Vista previa", contentOpen: "Abrir", contentLoadingPreview: "Cargando vista previa...", contentLimited: "Resultados limitados — precisa la búsqueda", - contentScopeDetail: - "Busca archivos UTF-8 guardados, incluidos ocultos y sin seguimiento. Respeta las reglas de exclusión. Excluye enlaces simbólicos y cambios sin guardar.", - contentScope: "Archivos guardados hasta 1 MiB · Sin distinguir mayúsculas", contentChanged: "El archivo cambió — la coincidencia ya no está aquí", placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index cea53a4f87..dac57a6f72 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -75,18 +75,14 @@ export const fr: TranslationResources = { }, commandCenter: { contentTitle: "Rechercher dans les fichiers", + content: "Contenu des fichiers", contentPlaceholder: "Rechercher dans les fichiers enregistrés...", contentUnavailable: "Espace de travail indisponible", contentUpdateHost: "Mettez cet hôte à jour pour rechercher dans les fichiers", - contentEmpty: "Rechercher dans les fichiers enregistrés", contentBack: "Retour aux résultats", - contentPreview: "Aperçu", contentOpen: "Ouvrir", contentLoadingPreview: "Chargement de l’aperçu...", contentLimited: "Résultats limités — précisez la recherche", - contentScopeDetail: - "Recherche dans les fichiers UTF-8 enregistrés, y compris cachés et non suivis. Respecte les règles d’exclusion. Exclut les liens symboliques et les modifications non enregistrées.", - contentScope: "Fichiers enregistrés jusqu’à 1 Mio · Casse ignorée", contentChanged: "Fichier modifié — la correspondance n’est plus à cet emplacement", placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index b408a69fd6..a8649e4ad0 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -74,18 +74,14 @@ export const ja: TranslationResources = { }, commandCenter: { contentTitle: "ファイル内容を検索", + content: "ファイル内容", contentPlaceholder: "保存済みファイルの内容を検索...", contentUnavailable: "ワークスペースを利用できません", contentUpdateHost: "ファイル内容を検索するにはホストを更新してください", - contentEmpty: "保存済みファイルの内容を検索", contentBack: "検索結果に戻る", - contentPreview: "プレビュー", contentOpen: "開く", contentLoadingPreview: "プレビューを読み込み中...", contentLimited: "結果が上限に達しました — 検索を絞り込んでください", - contentScopeDetail: - "非表示・未追跡のファイルを含む保存済み UTF-8 ファイルを検索します。除外ルールに従い、シンボリックリンクと未保存の編集は検索しません。", - contentScope: "1 MiB 以下の保存済みファイル · 大文字小文字を区別しない", contentChanged: "ファイルが変更され、この位置に一致する内容はありません", placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index d5eac90fb3..961af1776b 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -74,18 +74,14 @@ export const ko: TranslationResources = { }, commandCenter: { contentTitle: "파일 내용 검색", + content: "파일 내용", contentPlaceholder: "저장된 파일 내용 검색...", contentUnavailable: "작업 공간을 사용할 수 없음", contentUpdateHost: "파일 내용을 검색하려면 호스트를 업데이트하세요", - contentEmpty: "저장된 파일 내용 검색", contentBack: "결과로 돌아가기", - contentPreview: "미리보기", contentOpen: "열기", contentLoadingPreview: "미리보기 로딩 중...", contentLimited: "결과 제한 — 검색 범위를 좁히세요", - contentScopeDetail: - "숨김 파일과 추적되지 않은 파일을 포함한 저장된 UTF-8 파일을 검색합니다. 무시 규칙을 따르며 심볼릭 링크와 저장되지 않은 편집은 제외합니다.", - contentScope: "1 MiB 이하 저장된 파일 · 대소문자 구분 안 함", contentChanged: "파일이 변경되어 이 위치에 일치하는 내용이 없습니다", placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 7ec42f2914..56f65068ac 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -74,18 +74,14 @@ export const ptBR: TranslationResources = { }, commandCenter: { contentTitle: "Buscar conteúdo dos arquivos", + content: "Conteúdo dos arquivos", contentPlaceholder: "Buscar conteúdo salvo...", contentUnavailable: "Espaço de trabalho indisponível", contentUpdateHost: "Atualize este host para buscar conteúdo", - contentEmpty: "Buscar conteúdo salvo", contentBack: "Voltar aos resultados", - contentPreview: "Prévia", contentOpen: "Abrir", contentLoadingPreview: "Carregando prévia...", contentLimited: "Resultados limitados — refine a busca", - contentScopeDetail: - "Pesquisa arquivos UTF-8 salvos, incluindo ocultos e não rastreados. Respeita as regras de exclusão. Exclui links simbólicos e edições não salvas.", - contentScope: "Arquivos salvos até 1 MiB · Ignorar maiúsculas", contentChanged: "Arquivo alterado — a correspondência não está mais neste local", placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index dcab559c2a..12d2de8222 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -74,18 +74,14 @@ export const ru: TranslationResources = { }, commandCenter: { contentTitle: "Поиск по содержимому файлов", + content: "Содержимое файлов", contentPlaceholder: "Поиск в сохранённых файлах...", contentUnavailable: "Рабочее пространство недоступно", contentUpdateHost: "Обновите этот хост для поиска по содержимому", - contentEmpty: "Поиск в сохранённых файлах", contentBack: "Назад к результатам", - contentPreview: "Предпросмотр", contentOpen: "Открыть", contentLoadingPreview: "Загрузка предпросмотра...", contentLimited: "Результаты ограничены — уточните поиск", - contentScopeDetail: - "Поиск в сохранённых файлах UTF-8, включая скрытые и неотслеживаемые. Учитываются правила исключения. Символические ссылки и несохранённые изменения исключены.", - contentScope: "Сохранённые файлы до 1 МиБ · Без учёта регистра", contentChanged: "Файл изменён — совпадения больше нет в этом месте", placeholder: "Поиск команд, файлов, рабочих пространств и агентов...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index abf7478509..88757b2587 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -74,18 +74,14 @@ export const zhCN: TranslationResources = { }, commandCenter: { contentTitle: "搜索文件内容", + content: "文件内容", contentPlaceholder: "搜索已保存的文件内容...", contentUnavailable: "工作区不可用", contentUpdateHost: "请更新此主机以搜索文件内容", - contentEmpty: "搜索已保存的文件内容", contentBack: "返回结果", - contentPreview: "预览", contentOpen: "打开", contentLoadingPreview: "正在加载预览...", contentLimited: "结果已达上限 — 请缩小搜索范围", - contentScopeDetail: - "搜索已保存的 UTF-8 文件,包括隐藏和未跟踪的文件。遵循忽略规则,不搜索符号链接和未保存的编辑。", - contentScope: "不超过 1 MiB 的已保存文件 · 忽略大小写", contentChanged: "文件已更改 — 此位置不再匹配", placeholder: "搜索命令、文件、工作区和 Agent...", diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index 5e716b006a..7c60523d2a 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -75,7 +75,12 @@ export function usePaneContext(): PaneContextValue { } export function usePaneFocus(): PaneFocusContextValue { - const value = useContext(PaneFocusContext); + const value = usePaneFocusIfAny(); invariant(value, "PaneFocusContext is required"); return value; } + +/** Pane focus for subtrees that a pane may or may not own, such as an overlay preview. */ +export function usePaneFocusIfAny(): PaneFocusContextValue | null { + return useContext(PaneFocusContext); +}