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}
{preview.status === "loading" ? (
- {t("shell.commandCenter.contentLoadingPreview")}
+
+
+
) : null}
{preview.status === "error" ? (
-
- {preview.message}
+
+ {preview.message}
{t("common.actions.retry")}
@@ -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}
-
- {t("common.actions.retry")}
-
- >
- ) : 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);
+}