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