From eb1fe10375130419b95d19bba26c79de4a753e17 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 19:36:31 +0200 Subject: [PATCH] feat(app): search saved workspace contents from Command Center --- .github/workflows/ci.yml | 8 + docker/base/Dockerfile | 2 + ...orkspace-content-search-dependency.spec.ts | 59 +++ .../workspace-content-search-terminal.spec.ts | 46 ++ .../browser/workspace-content-search.spec.ts | 136 ++++++ .../support/helpers/daemon-websocket-gate.ts | 23 +- .../helpers/workspace-content-search.ts | 153 ++++++ .../app/src/command-center/command-center.tsx | 131 ++++-- .../workspace-content-search/index.tsx | 441 ++++++++++++++++++ .../internal/model.test.ts | 203 ++++++++ .../internal/model.ts | 201 ++++++++ .../command-center/workspace-registration.tsx | 23 +- .../src/file-explorer/preview-target.test.ts | 6 + .../app/src/file-explorer/preview-target.ts | 4 +- .../app/src/file-explorer/read-result.test.ts | 17 + packages/app/src/file-explorer/read-result.ts | 13 +- .../app/src/file-pane/editor/view.web.tsx | 21 +- packages/app/src/file-pane/pane.tsx | 14 +- packages/app/src/file-pane/source/index.tsx | 39 ++ .../app/src/file-pane/source/view.native.tsx | 84 +++- .../app/src/file-pane/source/view.web.tsx | 24 +- packages/app/src/i18n/resources/ar.ts | 15 + packages/app/src/i18n/resources/en.ts | 15 + packages/app/src/i18n/resources/es.ts | 15 + packages/app/src/i18n/resources/fr.ts | 15 + packages/app/src/i18n/resources/ja.ts | 15 + packages/app/src/i18n/resources/ko.ts | 15 + packages/app/src/i18n/resources/pt-BR.ts | 15 + packages/app/src/i18n/resources/ru.ts | 15 + packages/app/src/i18n/resources/zh-CN.ts | 15 + packages/app/src/keyboard/actions.ts | 1 + .../src/keyboard/keyboard-shortcuts.test.ts | 17 + .../app/src/keyboard/keyboard-shortcuts.ts | 21 +- packages/app/src/keyboard/route-shortcut.ts | 6 +- .../src/stores/keyboard-shortcuts-store.ts | 2 +- .../app/src/stores/workspace-layout-store.ts | 3 + .../app/src/workspace/file-open/index.test.ts | 45 +- packages/app/src/workspace/file-open/index.ts | 48 +- packages/client/src/daemon-client.ts | 28 ++ packages/client/src/index.ts | 1 + packages/protocol/src/messages.ts | 49 ++ .../authorization/operation-permissions.ts | 4 + .../workspace-content-search.e2e.test.ts | 39 ++ .../src/server/file-explorer/service.test.ts | 41 ++ .../src/server/file-explorer/service.ts | 16 +- packages/server/src/server/path-utils.ts | 14 +- packages/server/src/server/session.ts | 5 + .../session/files/workspace-files-session.ts | 109 +++-- .../server/src/server/websocket-server.ts | 1 + .../workspace-content-search/index.test.ts | 157 +++++++ .../server/workspace-content-search/index.ts | 268 +++++++++++ 51 files changed, 2503 insertions(+), 155 deletions(-) create mode 100644 packages/app/e2e/browser/workspace-content-search-dependency.spec.ts create mode 100644 packages/app/e2e/browser/workspace-content-search-terminal.spec.ts create mode 100644 packages/app/e2e/browser/workspace-content-search.spec.ts create mode 100644 packages/app/e2e/support/helpers/workspace-content-search.ts create mode 100644 packages/app/src/command-center/workspace-content-search/index.tsx create mode 100644 packages/app/src/command-center/workspace-content-search/internal/model.test.ts create mode 100644 packages/app/src/command-center/workspace-content-search/internal/model.ts create mode 100644 packages/app/src/file-pane/source/index.tsx create mode 100644 packages/server/src/server/daemon-e2e/workspace-content-search.e2e.test.ts create mode 100644 packages/server/src/server/workspace-content-search/index.test.ts create mode 100644 packages/server/src/server/workspace-content-search/index.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3452f38ec2..7acf34a083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,6 +152,14 @@ jobs: if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai + - name: Install ripgrep (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y ripgrep + + - name: Install ripgrep (Windows) + if: runner.os == 'Windows' + run: choco install ripgrep --yes --no-progress + - name: Build server dependencies run: npm run build:server-deps diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 378ec7118a..b9be4ac22b 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -50,11 +50,13 @@ RUN set -eux; \ ca-certificates \ curl \ git \ + ripgrep \ gosu \ lbzip2 \ openssh-client \ procps \ tini; \ + rg --version; \ rm -rf /var/lib/apt/lists/* COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs diff --git a/packages/app/e2e/browser/workspace-content-search-dependency.spec.ts b/packages/app/e2e/browser/workspace-content-search-dependency.spec.ts new file mode 100644 index 0000000000..cad874c72e --- /dev/null +++ b/packages/app/e2e/browser/workspace-content-search-dependency.spec.ts @@ -0,0 +1,59 @@ +import { mkdtemp, rm, symlink } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { test as base, expect } from "../support/fixtures"; +import { prepareContentSearch } from "../support/helpers/workspace-content-search"; + +const test = base.extend<{}, { searchHostPath: string }>({ + searchHostPath: [ + async ({ browserName }, provide) => { + void browserName; + const bin = await mkdtemp(path.join(os.tmpdir(), "search-host-path-")); + await symlink( + execFileSync("which", ["git"], { encoding: "utf8" }).trim(), + path.join(bin, "git"), + ); + await symlink(process.execPath, path.join(bin, "node")); + try { + await provide(bin); + } finally { + await rm(bin, { recursive: true, force: true }); + } + }, + { scope: "worker" }, + ], + e2eDaemonEnvironment: [ + async ({ searchHostPath }, provide) => { + await provide({ PATH: searchHostPath }); + }, + { scope: "worker" }, + ], +}); + +test("missing host ripgrep is actionable and Retry works after installation", async ({ + page, + withWorkspace, + searchHostPath, +}, testInfo) => { + const workspace = await withWorkspace(); + await prepareContentSearch(workspace); + await page.keyboard.press("Control+Shift+F"); + await page + .getByRole("textbox", { name: "Search saved file contents...", exact: true }) + .fill("needle"); + await expect( + page.getByText("Install ripgrep on this host and make rg available on its PATH, then retry", { + exact: true, + }), + ).toBeVisible(); + await page + .getByTestId("command-center-panel") + .screenshot({ path: testInfo.outputPath("missing-rg.png") }); + await symlink( + execFileSync("which", ["rg"], { encoding: "utf8" }).trim(), + path.join(searchHostPath, "rg"), + ); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible(); +}); diff --git a/packages/app/e2e/browser/workspace-content-search-terminal.spec.ts b/packages/app/e2e/browser/workspace-content-search-terminal.spec.ts new file mode 100644 index 0000000000..d0e87bd2e2 --- /dev/null +++ b/packages/app/e2e/browser/workspace-content-search-terminal.spec.ts @@ -0,0 +1,46 @@ +import { writeFile, readFile } from "node:fs/promises"; +import path from "node:path"; +import { test, expect } from "../support/fixtures"; +import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; +import { waitForTerminalContent } from "../support/helpers/terminal-perf"; + +test("content-search entry and dismissal preserve live shell input and terminal focus", async ({ + page, +}, testInfo) => { + const harness = await TerminalE2EHarness.create({ tempPrefix: "content-search-terminal-" }); + try { + const terminal = await harness.createTerminal({ name: "Search shortcut QA" }); + await writeFile( + path.join(harness.tempRepo.path, "source.ts"), + 'export const greeting = "needle";\n', + ); + await harness.openTerminal(page, { terminalId: terminal.id }); + await harness.setupPrompt(page); + await searchFromPendingShellCommand(page, harness, testInfo.outputPath("terminal-search.png")); + expect(await readFile(path.join(harness.tempRepo.path, "shell-input.txt"), "utf8")).toBe( + "untouched", + ); + } finally { + await harness.cleanup(); + } +}); + +async function searchFromPendingShellCommand( + page: import("@playwright/test").Page, + harness: TerminalE2EHarness, + screenshot: string, +) { + const terminal = harness.terminalSurface(page); + await terminal.pressSequentially("printf untouched > shell-input.txt", { delay: 0 }); + await page.keyboard.press("Control+Shift+F"); + const query = page.getByRole("textbox", { name: "Search saved file contents...", exact: true }); + await expect(query).toBeFocused(); + await query.pressSequentially("needle", { delay: 0 }); + await expect(page.getByRole("button", { name: /source.ts:1:26/ })).toBeVisible(); + await page.screenshot({ path: screenshot }); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("command-center-panel")).toBeHidden(); + await page.keyboard.press("Enter"); + await terminal.pressSequentially("printf '\\nSEARCH_SHELL_RESUMED\\n'\n", { delay: 0 }); + await waitForTerminalContent(page, (text) => text.includes("SEARCH_SHELL_RESUMED"), 10_000); +} diff --git a/packages/app/e2e/browser/workspace-content-search.spec.ts b/packages/app/e2e/browser/workspace-content-search.spec.ts new file mode 100644 index 0000000000..9800ddd3d8 --- /dev/null +++ b/packages/app/e2e/browser/workspace-content-search.spec.ts @@ -0,0 +1,136 @@ +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { expect } from "@playwright/test"; +import { test } from "../support/fixtures"; +import { + prepareContentSearch, + searchContents, + previewAndOpenSecondOccurrence, + openCompactPreview, + preserveDirtyBufferAndUndo, + recoverChangedAndMissingPreview, + searchContentsByTouch, +} from "../support/helpers/workspace-content-search"; + +test.use({ + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36", +}); +test("searches unopened saved contents, previews keyboard selection and opens the exact occurrence", async ({ + page, + withWorkspace, +}, testInfo) => { + const workspace = await withWorkspace(); + await prepareContentSearch(workspace); + await searchContents(page, "needle"); + await previewAndOpenSecondOccurrence(page, testInfo.outputPath("content-search-wide.png")); + await preserveDirtyBufferAndUndo(page, workspace); +}); +test("compact search keeps list, preview and Open in the existing popup", async ({ + page, + withWorkspace, +}, testInfo) => { + const workspace = await withWorkspace(); + await prepareContentSearch(workspace); + await page.setViewportSize({ width: 390, height: 844 }); + await searchContentsByTouch(page, "needle"); + await openCompactPreview(page, testInfo.outputPath("content-search-compact.png")); +}); + +test("changed saved files, empty results and occurrence limits stay explicit", async ({ + page, + withWorkspace, +}, testInfo) => { + const workspace = await withWorkspace(); + await prepareContentSearch(workspace); + await searchContents(page, "needle"); + await recoverChangedAndMissingPreview( + page, + workspace, + testInfo.outputPath("content-search-limited.png"), + ); +}); + +test("a host without content search advertises an update requirement without sending the new RPC", async ({ + page, + withWorkspace, +}) => { + const gate = await installDaemonWebSocketGate(page); + gate.setWorkspaceContentSearchStripped(); + const workspace = await withWorkspace(); + await prepareContentSearch(workspace); + await page.keyboard.press("Meta+Shift+F"); + await expect( + page.getByText("Update this host to search file contents", { exact: true }), + ).toBeVisible(); + await page.keyboard.press("Enter"); + await expect( + page.getByText("Update this host to search file contents", { exact: true }), + ).toBeVisible(); + expect(gate.getClientRequestCount("fs.content.search.request")).toBe(0); + await page.keyboard.press("Backspace"); + await expect( + page.getByRole("textbox", { + name: "Search commands, files, workspaces, and agents...", + exact: true, + }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await page.keyboard.press("Meta+p"); + await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused(); +}); + +import { openLiteralSourceOccurrence } from "../support/helpers/workspace-content-search"; + +test("SVG image opening stays visual while a saved occurrence opens exact source", async ({ + page, + withWorkspace, +}, testInfo) => { + const workspace = await withWorkspace(); + await openLiteralSourceOccurrence( + page, + workspace, + { + path: "icon.svg", + content: 'needle', + result: 'icon.svg:1:48 needle', + imageFirst: true, + }, + testInfo.outputPath("svg-source-open.png"), + ); +}); + +test("literal leading-space file identity survives preview and exact tab activation", async ({ + page, + withWorkspace, +}, testInfo) => { + const workspace = await withWorkspace(); + await openLiteralSourceOccurrence( + page, + workspace, + { + path: " leading.txt", + content: "needle", + result: " leading.txt:1:1 needle", + cursor: "Line 1, column 7", + }, + testInfo.outputPath("literal-path-open.png"), + ); +}); + +test("CR-only saved occurrences use the source editor line and exact selection", async ({ + page, + withWorkspace, +}, testInfo) => { + const workspace = await withWorkspace(); + await openLiteralSourceOccurrence( + page, + workspace, + { + path: "cr-only.txt", + content: "first\rneedle", + result: "cr-only.txt:2:1 needle", + cursor: "Line 2, column 7", + }, + testInfo.outputPath("cr-source-open.png"), + ); +}); diff --git a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts index 2f3d6b22c6..fcc9fa3ca1 100644 --- a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts @@ -104,19 +104,20 @@ function stripAssistantMessageId( return JSON.stringify(envelope); } -function stripCanonicalSubmittedPrompts( +function stripServerFeatures( message: string | Buffer, - enabled: boolean, + omittedFeatures: Set, messageType: unknown, ): string | Buffer { - if (!enabled || messageType !== "status" || typeof message !== "string") return message; + if (!omittedFeatures.size || messageType !== "status" || typeof message !== "string") + return message; const envelope = JSON.parse(message) as { message?: { payload?: { status?: unknown; features?: Record } }; payload?: { status?: unknown; features?: Record }; }; const payload = envelope.message?.payload ?? envelope.payload; if (payload?.status !== "server_info" || !payload.features) return message; - delete payload.features.canonicalSubmittedPrompts; + for (const feature of omittedFeatures) delete payload.features[feature]; return JSON.stringify(envelope); } @@ -299,7 +300,7 @@ export async function installDaemonWebSocketGate(page: Page) { let suppressAgentStream = false; let forceTimelineEpochReset = false; let stripAssistantMessageIds = false; - let stripCanonicalSubmittedPromptsFeature = false; + const omittedFeatures = new Set(); let shellToolCommandOverride: string | null = null; let failingTimelineAgentId: string | null = null; let holdingTimelineAgentId: string | null = null; @@ -509,11 +510,7 @@ export async function installDaemonWebSocketGate(page: Page) { serverMessage?.type, ); outboundMessage = rewriteShellToolCommand(outboundMessage, shellToolCommandOverride); - outboundMessage = stripCanonicalSubmittedPrompts( - outboundMessage, - stripCanonicalSubmittedPromptsFeature, - serverMessage?.type, - ); + outboundMessage = stripServerFeatures(outboundMessage, omittedFeatures, serverMessage?.type); const isTimelineResponse = serverMessage?.type === "fetch_agent_timeline_response"; if (isTimelineResponse) { outboundMessage = failTimelineResponse(outboundMessage, failingTimelineAgentId); @@ -823,8 +820,12 @@ export async function installDaemonWebSocketGate(page: Page) { setAssistantMessageIdsStripped(stripped: boolean): void { stripAssistantMessageIds = stripped; }, + setWorkspaceContentSearchStripped(): void { + omittedFeatures.add("workspaceContentSearch"); + }, setCanonicalSubmittedPromptsStripped(stripped: boolean): void { - stripCanonicalSubmittedPromptsFeature = stripped; + if (stripped) omittedFeatures.add("canonicalSubmittedPrompts"); + else omittedFeatures.delete("canonicalSubmittedPrompts"); }, setShellToolCommandOverride(command: string | null): void { shellToolCommandOverride = command; diff --git a/packages/app/e2e/support/helpers/workspace-content-search.ts b/packages/app/e2e/support/helpers/workspace-content-search.ts new file mode 100644 index 0000000000..11d41c1ddb --- /dev/null +++ b/packages/app/e2e/support/helpers/workspace-content-search.ts @@ -0,0 +1,153 @@ +import { unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, type Page } from "@playwright/test"; +import type { CreatedWorkspace } from "./with-workspace"; + +export async function prepareContentSearch(workspace: CreatedWorkspace) { + await writeFile( + path.join(workspace.repoPath, "search-a.ts"), + 'export const greeting = "NEEDLE";\n', + ); + await writeFile( + path.join(workspace.repoPath, "search-b.ts"), + 'const emoji = "é🙂 needle needle";\n', + ); + await workspace.navigateTo(); +} +export async function searchContents(page: Page, query: string) { + await page.keyboard.press("Meta+Shift+F"); + const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true }); + await expect(input).toBeVisible(); + await input.fill(query); + await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible(); +} +export async function previewAndOpenSecondOccurrence(page: Page, screenshot: string) { + const panel = page.getByTestId("command-center-panel"); + const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true }); + await expect(panel.getByTestId("file-source-editor")).toContainText("export const greeting"); + await page.keyboard.press("ArrowDown"); + await expect(panel.getByTestId("file-source-editor")).toContainText("const emoji"); + await expect(input).toBeFocused(); + const inputBox = await input.boundingBox(); + const previewBox = await panel.getByTestId("file-source-editor").boundingBox(); + expect(inputBox!.y).toBeGreaterThan(previewBox!.y + previewBox!.height); + await panel.screenshot({ path: screenshot }); + await page.keyboard.press("Enter"); + await expect(panel).toBeHidden(); + await expect(page.getByTestId("file-source-editor")).toContainText("const emoji"); + // The source view uses a real CodeMirror document; select-all is unnecessary and would hide a bad jump. + await expect(page.getByLabel("Line 1, column 26", { exact: true })).toBeVisible(); +} +export async function openCompactPreview(page: Page, screenshot: string) { + await page.getByRole("button", { name: /search-a.ts:1:26/ }).click(); + await expect(page.getByRole("button", { name: "Back to results", exact: true })).toBeVisible(); + await expect( + page.getByTestId("command-center-panel").getByTestId("file-source-editor"), + ).toContainText("export const greeting"); + await page.getByTestId("command-center-panel").screenshot({ path: screenshot }); + await page.getByRole("button", { name: "Open", exact: true }).click(); + await expect(page.getByTestId("command-center-panel")).toBeHidden(); + await expect(page.getByTestId("file-source-editor")).toContainText("export const greeting"); +} + +export async function preserveDirtyBufferAndUndo(page: Page, workspace: CreatedWorkspace) { + const source = page.getByTestId("file-source-editor").locator(".cm-content"); + await source.fill("const localUnsaved = 42;\n"); + await writeFile( + path.join(workspace.repoPath, "search-b.ts"), + 'const emoji = "é🙂 needle needle";\n// changed on disk\n', + ); + await expect(page.getByTestId("file-conflict-alert")).toBeVisible(); + await searchContents(page, "needle"); + await page.keyboard.press("ArrowDown"); + await expect( + page.getByTestId("command-center-panel").getByTestId("file-source-editor"), + ).toContainText("changed on disk"); + await page.keyboard.press("Enter"); + await expect(source).toContainText("localUnsaved"); + await expect(page.getByRole("status").filter({ hasText: "File changed" })).toContainText( + "saved match is no longer at this location", + ); + await source.press("Control+z"); + await expect(source).toContainText('const emoji = "é🙂 needle needle";'); + await expect(source).not.toContainText("localUnsaved"); +} + +export async function recoverChangedAndMissingPreview( + page: Page, + workspace: CreatedWorkspace, + screenshot: string, +) { + const panel = page.getByTestId("command-center-panel"); + const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true }); + await writeFile(path.join(workspace.repoPath, "search-b.ts"), "changed after search\n"); + await page.keyboard.press("ArrowDown"); + await expect(panel.getByRole("status")).toContainText( + "saved match is no longer at this location", + ); + await unlink(path.join(workspace.repoPath, "search-a.ts")); + await page.keyboard.press("ArrowUp"); + await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible(); + await writeFile( + path.join(workspace.repoPath, "search-a.ts"), + 'export const greeting = "NEEDLE";\n', + ); + await panel.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(panel.getByTestId("file-source-editor")).toContainText("NEEDLE"); + await input.fill("not-present-anywhere"); + await expect(panel.getByText("No matches", { exact: true })).toBeVisible(); + await writeFile(path.join(workspace.repoPath, "many.txt"), "needle\n".repeat(250)); + await input.fill("needle"); + await expect(panel.getByText(/Results limited/)).toBeVisible(); + await panel.screenshot({ path: screenshot }); +} + +export async function searchContentsByTouch(page: Page, query: string) { + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await page + .getByRole("textbox", { + name: "Search commands, files, workspaces, and agents...", + exact: true, + }) + .fill("Search file contents"); + await page.getByText("Search file contents", { exact: true }).click(); + await page + .getByRole("textbox", { name: "Search saved file contents...", exact: true }) + .fill(query); + await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible(); +} + +export async function openLiteralSourceOccurrence( + page: Page, + workspace: CreatedWorkspace, + input: { path: string; content: string; result: string; cursor?: string; imageFirst?: boolean }, + screenshot: string, +) { + await writeFile(path.join(workspace.repoPath, input.path), input.content); + await writeFile(path.join(workspace.repoPath, "leading.txt"), "different file"); + await workspace.navigateTo(); + if (input.imageFirst) { + await page.keyboard.press("Meta+p"); + await page.getByRole("textbox", { name: "Search files...", exact: true }).fill(input.path); + await page.getByTestId("command-center-panel").getByText(input.path, { exact: true }).click(); + await expect(page.getByTestId("image-file-preview")).toBeVisible(); + } + await page.keyboard.press("Meta+Shift+F"); + await page + .getByRole("textbox", { name: "Search saved file contents...", exact: true }) + .fill("needle"); + const panel = page.getByTestId("command-center-panel"); + await expect(panel.getByRole("button", { name: input.result, exact: true })).toBeVisible(); + await expect(panel.getByTestId("file-source-editor")).toContainText("needle"); + await expect(panel.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0); + await page.keyboard.press("Enter"); + await expect(panel).toBeHidden(); + const source = page.getByTestId("file-source-editor"); + await expect(source).toContainText("needle"); + await expect(source).not.toContainText("different file"); + await expect(source.locator(".cm-selectionBackground")).toBeVisible(); + await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0); + if (input.cursor) await expect(page.getByLabel(input.cursor, { exact: true })).toBeVisible(); + await page.screenshot({ path: screenshot }); +} diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index e909bf28e5..494f66ff58 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -1,3 +1,4 @@ +import { WorkspaceContentSearch } from "./workspace-content-search"; import { FlatList, Modal, @@ -91,6 +92,7 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({ color: theme.colors.foregroundMuted, })); const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"]; +const CONTENT_SEARCH_SNAP_POINTS = ["90%"]; const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const; function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { @@ -259,7 +261,7 @@ function useCommandCenterState(): CommandCenterState { error: fileSearchError, openFile, } = useWorkspaceFileSearch({ - enabled: open && (scope === "files" || Boolean(query.trim())), + enabled: open && scope !== "content" && (scope === "files" || Boolean(query.trim())), query, }); const fileSections = useMemo(() => { @@ -584,6 +586,7 @@ export function CommandCenter() { const isCompact = useIsCompactFormFactor(); const showBottomSheet = isCompact && isNative; const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet); + const contentKeyHandler = useRef<((key: string) => boolean) | null>(null); const listRef = useRef>(null); const bottomSheetListRef = useRef(null); const bottomSheetInputRef = useRef(null); @@ -707,7 +710,11 @@ export function CommandCenter() { const submit = useCallback(() => state.key("Enter"), [state]); const handleWebOverlayKeyDown = useCallback( (event: KeyboardEvent) => { - if (!state.key(event.key)) return false; + const handled = + state.scope === "content" + ? (contentKeyHandler.current?.(event.key) ?? false) + : state.key(event.key); + if (!handled) return false; event.preventDefault(); return true; }, @@ -730,7 +737,9 @@ export function CommandCenter() { - - {state.scope === "files" ? ( - - ) : null} - - - - {fileSearchError} - - - ); - } - if (!state.open) return null; - return ( - - - - - - + ) : ( + <> + {state.scope === "files" ? ( ) : null} - {fileSearchError} - + + + )} + + ); + } + if (!state.open) return null; + return ( + + + + + + {state.scope === "content" ? ( + + ) : ( + <> + + {state.scope === "files" ? ( + + ) : null} + + + + {fileSearchError} + + + )} @@ -858,6 +898,7 @@ const styles = StyleSheet.create((theme) => ({ paddingTop: theme.spacing[12], }, backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" }, + contentPanel: { width: 1000, maxWidth: "96%", height: "75%" }, panel: { width: 640, height: 560, diff --git a/packages/app/src/command-center/workspace-content-search/index.tsx b/packages/app/src/command-center/workspace-content-search/index.tsx new file mode 100644 index 0000000000..60663a8ea0 --- /dev/null +++ b/packages/app/src/command-center/workspace-content-search/index.tsx @@ -0,0 +1,441 @@ +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { + FlatList, + Pressable, + Text, + View, + type NativeSyntheticEvent, + type TextInputKeyPressEventData, +} from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +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"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { usePanelStore } from "@/stores/panel-store"; +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"; + +const ThemedSpinner = withUnistyles(LoadingSpinner, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +import type { WorkspaceContentMatch } from "@getpaseo/protocol/messages"; +import type { ContentSearchSnapshot } from "./internal/model"; +interface Props { + 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(); + const selection = useActiveWorkspaceSelection(); + const serverId = selection?.serverId ?? null; + const workspaceId = selection?.workspaceId ?? null; + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const session = useSessionStore((state) => (serverId ? state.sessions[serverId] : undefined)); + const unavailable = + !session?.client || + !cwd || + !serverId || + !workspaceId || + !session.serverInfo?.features?.workspaceContentSearch; + useEffect(() => { + if (!unavailable) return; + keyHandler.current = (key) => { + if (key === "Escape") close(); + else if (key === "Backspace") clearScope(); + return ["Enter", "ArrowUp", "ArrowDown", "Escape", "Backspace"].includes(key); + }; + return () => { + keyHandler.current = null; + }; + }, [unavailable, keyHandler, close, clearScope]); + if (!session?.client || !cwd || !serverId || !workspaceId) + return {t("shell.commandCenter.contentUnavailable")}; + // COMPAT(workspaceContentSearch): added in v0.8.0, remove after 2027-03-09 once daemon floor >= v0.8.0. + if (!session.serverInfo?.features?.workspaceContentSearch) + return {t("shell.commandCenter.contentUpdateHost")}; + return ( + + ); +} +function ContentSearch({ + transport, + cwd, + serverId, + workspaceId, + compact, + bottomSheet, + close, + clearScope, + keyHandler, +}: Props & { + transport: ContentSearchTransport; + cwd: string; + serverId: string; + workspaceId: string; +}) { + const { t } = useTranslation(); + const [previewPage, setPreviewPage] = useState(false); + const closeRef = useRef(close); + closeRef.current = close; + const model = useMemo( + () => + new WorkspaceContentSearchModel({ + cwd, + transport, + open(location) { + clearCommandCenterFocusRestoreElement(); + const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + if (!workspaceKey) return; + const layout = useWorkspaceLayoutStore.getState(); + const tabId = layout.openTab({ + workspaceKey, + target: createWorkspaceFileTabTarget(location), + intent: "reveal", + }); + if (tabId) layout.focusTab(workspaceKey, tabId); + usePanelStore.getState().showMobileAgent(); + closeRef.current(); + }, + }), + [cwd, transport, serverId, workspaceId], + ); + useEffect(() => () => model.dispose(), [model]); + const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); + const listRef = useRef(null); + useEffect(() => { + if (state.matches.length && (!compact || !previewPage)) + listRef.current?.scrollToIndex({ + index: state.activeIndex, + animated: false, + viewPosition: 0.5, + }); + }, [compact, previewPage, state.activeIndex, state.matches]); + const key = useCallback( + (pressed: string) => { + if (pressed === "Escape") { + close(); + return true; + } + if (pressed === "Backspace" && !state.query) { + clearScope(); + return true; + } + if (pressed === "ArrowDown" || pressed === "ArrowUp") { + model.move(pressed === "ArrowDown" ? 1 : -1); + return true; + } + if (pressed === "Enter") { + model.openSelected(); + return true; + } + return false; + }, + [close, clearScope, model, state.query], + ); + useEffect(() => { + keyHandler.current = key; + return () => { + 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); + if (compact) setPreviewPage(true); + }, + [model, compact], + ); + const back = useCallback(() => setPreviewPage(false), []); + const renderItem = useCallback( + ({ item, index }: { item: WorkspaceContentMatch; index: number }) => ( + + ), + [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]); + return ( + + + {!compact || !previewPage ? ( + + + + ) : null} + {!compact || previewPage ? ( + + ) : null} + + + + + + + + ); +} +function ResultRow({ + item, + index, + selected, + select, +}: { + item: WorkspaceContentMatch; + index: number; + selected: boolean; + select(index: number): void; +}) { + const onPress = useCallback(() => select(index), [index, select]); + const accessibilityState = useMemo(() => ({ selected }), [selected]); + return ( + + + {item.path}:{item.line}:{item.columnStart} + + + {item.snippet.slice(0, item.snippetMatchStart)} + + {item.snippet.slice(item.snippetMatchStart, item.snippetMatchEnd)} + + {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 }; +} +function ContentPreview({ + state, + model, + compact, + back, +}: { + state: ContentSearchSnapshot; + model: WorkspaceContentSearchModel; + compact: boolean; + back(): void; +}) { + const { t } = useTranslation(); + const match = state.matches[state.activeIndex]; + const preview = state.preview; + const location = useMemo( + () => + match + ? { + path: match.path, + lineStart: match.line, + columnStart: match.columnStart, + columnEnd: match.columnEnd, + expectedText: match.text, + } + : null, + [match], + ); + return ( + + + {compact ? ( + + ) : null} + + {match?.path ?? t("shell.commandCenter.contentPreview")} + + + + {preview.status === "loading" ? ( + {t("shell.commandCenter.contentLoadingPreview")} + ) : null} + {preview.status === "error" ? ( + + {preview.message} + + + ) : null} + {preview.status === "ready" && location ? ( + + ) : null} + + ); +} +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} + + ); +} +const styles = StyleSheet.create((theme) => ({ + root: { flex: 1, minHeight: 0 }, + body: { flex: 1, minHeight: 0, flexDirection: "row" }, + results: { + width: "40%", + minHeight: 0, + borderRightWidth: 1, + borderRightColor: theme.colors.border, + }, + full: { width: "100%", borderRightWidth: 0 }, + row: { + height: 56, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + gap: theme.spacing[1], + }, + selected: { backgroundColor: theme.colors.surface2 }, + path: { color: theme.colors.foreground, fontSize: theme.fontSize.sm }, + snippet: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontFamily: theme.fontFamily.mono, + }, + match: { + color: theme.colors.foreground, + backgroundColor: theme.colors.terminal.selectionBackground, + }, + preview: { flex: 1, minWidth: 0, minHeight: 0 }, + previewHeader: { + flexDirection: "row", + alignItems: "center", + padding: 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], + 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 new file mode 100644 index 0000000000..12d1986587 --- /dev/null +++ b/packages/app/src/command-center/workspace-content-search/internal/model.test.ts @@ -0,0 +1,203 @@ +import { expect, test } from "vitest"; +import type { FileReadResult } from "@getpaseo/client"; +import type { + WorkspaceContentMatch, + WorkspaceContentSearchResult, +} from "@getpaseo/protocol/messages"; +import { WorkspaceContentSearchModel, type ContentSearchTransport } from "./model"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function match(path: string, text = "NEEDLE"): WorkspaceContentMatch { + return { + path, + line: 1, + columnStart: 1, + columnEnd: text.length + 1, + text, + snippet: text, + snippetMatchStart: 0, + snippetMatchEnd: text.length, + }; +} +function result(matches: WorkspaceContentMatch[]): WorkspaceContentSearchResult { + return { status: "ok", matches, limited: false }; +} +function file(path: string, text: string): FileReadResult { + return { + path, + kind: "text", + mime: "text/plain", + bytes: new TextEncoder().encode(text), + size: text.length, + modifiedAt: "2026-09-09", + }; +} + +test("obsolete queries cannot restore results and opening carries the original case-insensitive match", async () => { + const first = deferred(); + const second = deferred(); + const startedFirst = deferred(); + const startedSecond = deferred(); + const signals: AbortSignal[] = []; + const opened: unknown[] = []; + const transport: ContentSearchTransport = { + searchWorkspaceContent: ({ query }, { signal }) => { + signals.push(signal); + if (query === "old") { + startedFirst.resolve(); + return first.promise; + } + startedSecond.resolve(); + return second.promise; + }, + readFile: async (path) => file(path, "NEEDLE"), + }; + const model = new WorkspaceContentSearchModel({ + cwd: "/workspace", + transport, + open: (location) => opened.push(location), + debounceMs: 0, + }); + try { + model.setQuery("old"); + await startedFirst.promise; + model.setQuery("needle"); + await startedSecond.promise; + expect(signals[0].aborted).toBe(true); + second.resolve(result([match("a.ts")])); + await Promise.resolve(); + await Promise.resolve(); + first.resolve(result([match("obsolete.ts")])); + await Promise.resolve(); + expect(model.getSnapshot()).toMatchObject({ + query: "needle", + status: "ready", + matches: [match("a.ts")], + preview: { status: "ready", content: "NEEDLE" }, + }); + model.openSelected(); + expect(opened).toEqual([ + { path: "a.ts", lineStart: 1, columnStart: 1, columnEnd: 7, expectedText: "NEEDLE" }, + ]); + } finally { + model.dispose(); + } +}); + +test("selection bounds preview work to one read plus the latest target, and reads current saved content", async () => { + const started = deferred(); + const searched = deferred(); + const a = deferred(); + const c = deferred(); + const paths: string[] = []; + const model = new WorkspaceContentSearchModel({ + cwd: "/workspace", + debounceMs: 0, + open() {}, + transport: { + searchWorkspaceContent: () => { + started.resolve(); + return searched.promise; + }, + readFile: (_cwd, path) => { + paths.push(path); + return path === "a" ? a.promise : c.promise; + }, + }, + }); + try { + model.setQuery("needle"); + await started.promise; + searched.resolve(result([match("a"), match("b"), match("c")])); + await Promise.resolve(); + model.select(1); + model.select(2); + expect(paths).toEqual(["a"]); + a.resolve(file("a", "NEEDLE")); + await Promise.resolve(); + expect(paths).toEqual(["a", "c"]); + c.resolve(file("c", "changed")); + await Promise.resolve(); + expect(model.getSnapshot().preview).toMatchObject({ + status: "ready", + path: "c", + content: "changed", + }); + } finally { + model.dispose(); + } +}); + +test("a new query refreshes the saved preview even when its selected path is unchanged", async () => { + const reads: Array>> = []; + const searches: Array>> = []; + const model = new WorkspaceContentSearchModel({ + cwd: "/workspace", + debounceMs: 0, + open() {}, + transport: { + searchWorkspaceContent: () => { + const pending = deferred(); + searches.push(pending); + return pending.promise; + }, + readFile: () => { + const pending = deferred(); + reads.push(pending); + return pending.promise; + }, + }, + }); + try { + model.setQuery("first"); + await expect.poll(() => searches.length).toBe(1); + searches[0].resolve(result([match("same.ts")])); + await Promise.resolve(); + model.setQuery("second"); + await expect.poll(() => searches.length).toBe(2); + searches[1].resolve(result([match("same.ts")])); + await Promise.resolve(); + reads[0].resolve(file("same.ts", "old saved text")); + await Promise.resolve(); + expect(reads).toHaveLength(2); + reads[1].resolve(file("same.ts", "NEEDLE current saved text")); + await Promise.resolve(); + expect(model.getSnapshot().preview).toMatchObject({ + status: "ready", + content: "NEEDLE current saved text", + }); + } finally { + model.dispose(); + } +}); + +test("textual image occurrences expose saved source without changing the read kind", async () => { + const model = new WorkspaceContentSearchModel({ + cwd: "/workspace", + debounceMs: 0, + open() {}, + transport: { + searchWorkspaceContent: async () => result([match("icon.svg")]), + readFile: async () => ({ + ...file("icon.svg", "NEEDLE"), + kind: "image", + mime: "image/svg+xml", + }), + }, + }); + try { + model.setQuery("needle"); + await expect.poll(() => model.getSnapshot().preview.status).toBe("ready"); + expect(model.getSnapshot().preview).toMatchObject({ content: "NEEDLE" }); + } 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 new file mode 100644 index 0000000000..3725d7db51 --- /dev/null +++ b/packages/app/src/command-center/workspace-content-search/internal/model.ts @@ -0,0 +1,201 @@ +import type { + WorkspaceContentMatch, + WorkspaceContentSearchResult, +} from "@getpaseo/protocol/messages"; +import { explorerFileFromReadResult } from "@/file-explorer/read-result"; +import type { FileReadResult } from "@getpaseo/client"; +import type { WorkspaceFileLocation } from "@/workspace/file-open"; + +export interface ContentSearchTransport { + searchWorkspaceContent( + input: { cwd: string; query: string }, + options: { signal: AbortSignal }, + ): Promise; + readFile( + cwd: string, + path: string, + requestId?: string, + maxBytes?: number, + ): Promise; +} +type Preview = + | { status: "empty" | "loading" } + | { status: "error"; message: string } + | { status: "ready"; path: string; content: string; size: number }; +export interface ContentSearchSnapshot { + query: string; + status: "idle" | "searching" | "ready" | "error"; + message: string; + matches: WorkspaceContentMatch[]; + limited: boolean; + activeIndex: number; + preview: Preview; +} + +export class WorkspaceContentSearchModel { + private snapshot: ContentSearchSnapshot = { + query: "", + status: "idle", + message: "", + matches: [], + limited: false, + activeIndex: 0, + preview: { status: "empty" }, + }; + private listeners = new Set<() => void>(); + private controller: AbortController | null = null; + private timer: ReturnType | undefined; + private disposed = false; + private reading = false; + private file: { path: string; content: string; size: number } | null = null; + constructor( + private readonly options: { + cwd: string; + transport: ContentSearchTransport; + open(location: WorkspaceFileLocation): void; + debounceMs?: number; + }, + ) {} + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + getSnapshot = () => this.snapshot; + private update(next: Partial) { + this.snapshot = { ...this.snapshot, ...next }; + for (const listener of this.listeners) listener(); + } + setQuery = (query: string) => { + this.controller?.abort(); + this.file = null; + clearTimeout(this.timer); + this.update({ + query, + status: query ? "searching" : "idle", + message: "", + matches: [], + limited: false, + activeIndex: 0, + preview: { status: "empty" }, + }); + if (!query || this.disposed) return; + const controller = new AbortController(); + this.controller = controller; + this.timer = setTimeout( + () => void this.search(query, controller), + this.options.debounceMs ?? 100, + ); + }; + private async search(query: string, controller: AbortController) { + try { + const result = await this.options.transport.searchWorkspaceContent( + { cwd: this.options.cwd, query }, + { signal: controller.signal }, + ); + if (this.disposed || controller.signal.aborted) return; + if (result.status === "error") { + this.update({ status: "error", message: result.message }); + return; + } + this.update({ status: "ready", matches: result.matches, limited: result.limited }); + this.select(0); + } catch (error) { + if (!this.disposed && !controller.signal.aborted) + this.update({ + status: "error", + message: error instanceof Error ? error.message : "File search failed", + }); + } + } + select = (index: number) => { + const activeIndex = Math.max(0, Math.min(index, this.snapshot.matches.length - 1)); + this.update({ activeIndex }); + void this.preview(); + }; + move = (direction: number) => + this.select( + (this.snapshot.activeIndex + direction + this.snapshot.matches.length) % + (this.snapshot.matches.length || 1), + ); + private async preview() { + const match = this.snapshot.matches[this.snapshot.activeIndex]; + if (!match) return; + if (this.file?.path === match.path) { + this.update({ + preview: { + status: "ready", + ...this.file, + }, + }); + return; + } + this.update({ preview: { status: "loading" } }); + if (this.reading) return; + this.reading = true; + const path = match.path; + const query = this.controller; + try { + const file = await this.options.transport.readFile( + this.options.cwd, + path, + undefined, + 1_048_576, + ); + 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, + }; + } + } catch (error) { + if ( + !this.disposed && + query === this.controller && + this.snapshot.matches[this.snapshot.activeIndex]?.path === path + ) { + this.update({ + preview: { + status: "error", + message: error instanceof Error ? error.message : "Preview unavailable", + }, + }); + return; + } + } finally { + this.reading = false; + } + if (!this.disposed) void this.preview(); + } + retry = () => { + this.file = null; + this.setQuery(this.snapshot.query); + }; + retryPreview = () => { + this.file = null; + void this.preview(); + }; + openSelected = () => { + const match = this.snapshot.matches[this.snapshot.activeIndex]; + if (!match || this.snapshot.status !== "ready") return; + this.options.open({ + path: match.path, + lineStart: match.line, + columnStart: match.columnStart, + columnEnd: match.columnEnd, + expectedText: match.text, + }); + }; + dispose = () => { + this.disposed = true; + this.controller?.abort(); + clearTimeout(this.timer); + this.listeners.clear(); + this.file = null; + }; +} diff --git a/packages/app/src/command-center/workspace-registration.tsx b/packages/app/src/command-center/workspace-registration.tsx index 70789e5353..4b9f63cfe6 100644 --- a/packages/app/src/command-center/workspace-registration.tsx +++ b/packages/app/src/command-center/workspace-registration.tsx @@ -1,3 +1,4 @@ +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useCallback, useMemo, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { @@ -327,10 +328,30 @@ export function useWorkspaceCommandCenterActions(): void { ], ); + const allActions = useMemo( + () => [ + ...actions, + { + id: "workspace-content-search", + group: "actions" as const, + groupRank: 0, + rank: 10, + visibility: "query" as const, + keywords: ["search", "find", "contents", "files", "text"], + run: () => useKeyboardShortcutsStore.getState().setCommandCenterOpen(true, "content"), + presentation: { + kind: "action" as const, + title: t("shell.commandCenter.contentTitle"), + sectionTitle: t("shell.commandCenter.actions"), + }, + }, + ], + [actions, t], + ); useCommandCenterActions({ sourceId: "workspace", enabled: Boolean(serverId && cwd), - actions, + actions: allActions, }); } diff --git a/packages/app/src/file-explorer/preview-target.test.ts b/packages/app/src/file-explorer/preview-target.test.ts index 712cfc7012..705982bf8a 100644 --- a/packages/app/src/file-explorer/preview-target.test.ts +++ b/packages/app/src/file-explorer/preview-target.test.ts @@ -72,3 +72,9 @@ describe("resolveFilePreviewReadTarget", () => { ).toBeNull(); }); }); + +it("preserves literal whitespace for a source read target", () => { + expect( + resolveFilePreviewReadTarget({ path: " leading.txt ", workspaceRoot: "/workspace " }), + ).toEqual({ cwd: "/workspace ", path: " leading.txt " }); +}); diff --git a/packages/app/src/file-explorer/preview-target.ts b/packages/app/src/file-explorer/preview-target.ts index c47bde70e3..b4ec6dc333 100644 --- a/packages/app/src/file-explorer/preview-target.ts +++ b/packages/app/src/file-explorer/preview-target.ts @@ -61,7 +61,7 @@ export function resolveFilePreviewReadTarget(input: { path: string; workspaceRoot?: string; }): FilePreviewReadTarget | null { - const previewPath = input.path.trim(); + const previewPath = input.path; if (!previewPath) { return null; } @@ -73,7 +73,7 @@ export function resolveFilePreviewReadTarget(input: { }; } - const workspaceRoot = input.workspaceRoot?.trim(); + const workspaceRoot = input.workspaceRoot; if (!isAbsolutePath(previewPath)) { if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) { return null; diff --git a/packages/app/src/file-explorer/read-result.test.ts b/packages/app/src/file-explorer/read-result.test.ts index fa5d3bb882..50a4890ebf 100644 --- a/packages/app/src/file-explorer/read-result.test.ts +++ b/packages/app/src/file-explorer/read-result.test.ts @@ -32,3 +32,20 @@ describe("explorerFileFromReadResult", () => { expect(embedded.hasBom).toBe(false); }); }); + +it("keeps SVG image identity and exposes valid UTF-8 source for occurrence navigation", () => { + const file = { + ...textRead(new TextEncoder().encode("needle")), + kind: "image" as const, + mime: "image/svg+xml", + path: "icon.svg", + }; + expect(explorerFileFromReadResult(file)).toMatchObject({ + kind: "image", + content: "needle", + }); + expect(explorerFileFromReadResult({ ...file, bytes: new Uint8Array([255]) })).toMatchObject({ + kind: "image", + content: undefined, + }); +}); diff --git a/packages/app/src/file-explorer/read-result.ts b/packages/app/src/file-explorer/read-result.ts index 2e36add959..bde89bf1f8 100644 --- a/packages/app/src/file-explorer/read-result.ts +++ b/packages/app/src/file-explorer/read-result.ts @@ -2,12 +2,21 @@ import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; import type { ExplorerFile } from "@/stores/session-store"; export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile { - const isText = file.kind === "text"; + // SVG keeps its normal image presentation while exposing its UTF-8 source to line targets. + const isText = file.kind === "text" || file.mime === "image/svg+xml"; + let content: string | undefined; + if (isText) { + try { + content = new TextDecoder("utf-8", { fatal: true }).decode(file.bytes); + } catch (error) { + if (file.kind === "text") throw error; + } + } return { path: file.path, kind: file.kind, encoding: isText ? "utf-8" : "none", - content: isText ? new TextDecoder().decode(file.bytes) : undefined, + content, hasBom: isText && hasUtf8Bom(file.bytes), mimeType: file.mime, size: file.size, diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx index d396697c41..193e4d7f80 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { FileFind, FileFindModel } from "../find/index.web"; import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state"; @@ -5,7 +6,7 @@ import { EditorView } from "@codemirror/view"; import { getLanguageForFile } from "@getpaseo/highlight"; import { getCM, vim } from "@replit/codemirror-vim"; import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; -import type { WorkspaceFileLocation } from "@/workspace/file-open"; +import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open"; import type { FileEditorModel } from "./model"; import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web"; @@ -39,6 +40,7 @@ export function FileEditorView({ onCursorChange, onVimModeChange, }: FileEditorViewProps) { + const { t } = useTranslation(); const [find] = useState(() => new FileFindModel()); const hostRef = useRef(null); const viewRef = useRef(null); @@ -102,15 +104,12 @@ export function FileEditorView({ useEffect(() => { const view = viewRef.current; if (!view || !location.lineStart) return; - const lineStart = Math.min(location.lineStart, view.state.doc.lines); - const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines); - const from = view.state.doc.line(lineStart).from; - const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to; + const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location); view.dispatch({ - selection: { anchor: from, head: lineEnd > lineStart ? to : from }, + selection: { anchor: from, head: to }, effects: EditorView.scrollIntoView(from, { y: "center" }), }); - }, [location.lineEnd, location.lineStart, navigationRevision]); + }, [location, navigationRevision]); useEffect(() => { viewRef.current?.dispatch({ @@ -143,8 +142,13 @@ export function FileEditorView({ return () => cm.off("vim-mode-change", handleModeChange); }, [onVimModeChange, vimEnabled]); + const changed = resolveWorkspaceFileSelection( + snapshot.content.replace(/\r\n?/g, "\n"), + location, + ).changed; return (
+ {changed ?
{t("shell.commandCenter.contentChanged")}
: null}
(); +// The Find widget floats inside this frame, so it stays relative; the change notice is the +// only row that shares the column with the editor host. const FRAME_STYLE = { display: "flex", + flexDirection: "column", position: "relative", flex: 1, minHeight: 0, diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index a5630ed82f..557482d868 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -58,14 +58,6 @@ interface FilePreviewBodyProps { type TextExplorerFile = ExplorerFile & { kind: "text" }; -function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - function formatFileSize({ size }: { size: number }): string { if (size < 1024) { return `${size} B`; @@ -164,7 +156,7 @@ function FilePreviewBody({ ); } - if (preview.kind === "text") { + if (preview.kind === "text" || (location.lineStart && preview.content !== undefined)) { if (renderKind === "html") { // The HTML document owns its own scrolling, so no ScrollView wrapper here. return ( @@ -239,8 +231,8 @@ export function FilePane({ const supportsEditing = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true, ); - const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); - const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]); + const normalizedWorkspaceRoot = workspaceRoot; + const normalizedFilePath = location.path || null; const readTarget = useMemo( () => normalizedFilePath diff --git a/packages/app/src/file-pane/source/index.tsx b/packages/app/src/file-pane/source/index.tsx new file mode 100644 index 0000000000..c31644cfeb --- /dev/null +++ b/packages/app/src/file-pane/source/index.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from "react-i18next"; +import { useMemo } from "react"; +import { UnistylesRuntime } from "react-native-unistyles"; +import type { WorkspaceFileLocation } from "@/workspace/file-open"; +import { FileSourceView } from "./view"; + +/** Read-only source preview. The source module owns platform rendering and its theme. */ +export function WorkspaceFileSource(props: { + content: string; + filename: string; + location: WorkspaceFileLocation; + navigationRevision: number; + size: number; +}) { + const { t } = useTranslation(); + const theme = UnistylesRuntime.getTheme(); + 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.native.tsx b/packages/app/src/file-pane/source/view.native.tsx index de7c5eeae1..3ef40c93d6 100644 --- a/packages/app/src/file-pane/source/view.native.tsx +++ b/packages/app/src/file-pane/source/view.native.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useRef } from "react"; -import { FlatList, Text, View, type ListRenderItem } from "react-native"; +import { useTranslation } from "react-i18next"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { FlatList, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; @@ -58,14 +59,16 @@ function VirtualizedSource({ }: Omit & { presentation: "highlighted" | "plain"; }) { + const { t } = useTranslation(); const listRef = useRef>(null); const lines = useMemo(() => { + const source = content.replace(/\r\n?/g, "\n"); if (presentation === "highlighted") - return highlightCode(content, filename).map((tokens, index) => ({ + return highlightCode(source, filename).map((tokens, index) => ({ number: index + 1, tokens, })); - return content + return source .split("\n") .map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] })); }, [content, filename, presentation]); @@ -77,29 +80,63 @@ function VirtualizedSource({ viewPosition: 0.5, }); }, [lines.length, location.lineStart, navigationRevision]); + const selectedLine = lines[(location.lineStart ?? 1) - 1]?.tokens + .map((token) => token.text) + .join(""); + const changed = + location.expectedText !== undefined && + selectedLine?.slice((location.columnStart ?? 1) - 1, (location.columnEnd ?? 1) - 1) !== + location.expectedText; + const renderLine = useCallback( + ({ item }: { item: SourceLine }) => ( + + ), + [changed, location], + ); return ( - + + {changed ? ( + + {t("shell.commandCenter.contentChanged")} + + ) : null} + + ); } -function SourceLineView({ line }: { line: SourceLine }) { +function SourceLineView({ line, start, end }: { line: SourceLine; start?: number; end?: number }) { + let offset = 0; return ( {line.number} - {line.tokens.map((token) => ( - - {token.text} - - ))} + {line.tokens.map((token) => { + const tokenStart = offset; + offset += token.text.length; + const from = Math.max(0, Math.min(token.text.length, (start ?? 1) - 1 - tokenStart)); + const to = Math.max(from, Math.min(token.text.length, (end ?? 1) - 1 - tokenStart)); + return ( + + {token.text.slice(0, from)} + {token.text.slice(from, to)} + {token.text.slice(to)} + + ); + })} ); @@ -113,9 +150,14 @@ function sourceLineLayout(_data: ArrayLike | null | undefined, index return { length: 20, offset: index * 20, index }; } -const renderSourceLine: ListRenderItem = ({ item }) => ; - const styles = StyleSheet.create((theme) => ({ + root: { flex: 1 }, + notice: { + padding: theme.spacing[2], + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + match: { backgroundColor: theme.colors.terminal.selectionBackground }, unsupported: { flex: 1, alignItems: "center", diff --git a/packages/app/src/file-pane/source/view.web.tsx b/packages/app/src/file-pane/source/view.web.tsx index 3499ac7d8e..bc2c346046 100644 --- a/packages/app/src/file-pane/source/view.web.tsx +++ b/packages/app/src/file-pane/source/view.web.tsx @@ -1,9 +1,10 @@ +import { useTranslation } from "react-i18next"; import { useEffect, useRef, useState } from "react"; import { FileFind, FileFindModel } from "../find/index.web"; import { Compartment, EditorState } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; +import { EditorView, drawSelection } from "@codemirror/view"; import { getLanguageForFile } from "@getpaseo/highlight"; -import type { WorkspaceFileLocation } from "@/workspace/file-open"; +import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open"; import type { EditorVisualTheme } from "../editor/extensions.web"; import { editorTheme } from "../editor/extensions.web"; import { selectSourcePresentation, type SourcePresentation } from "./presentation"; @@ -60,6 +61,7 @@ function ReadonlyCodeMirror({ }: Omit & { presentation: Exclude; }) { + const { t } = useTranslation(); const [find] = useState(() => new FileFindModel()); const hostRef = useRef(null); const viewRef = useRef(null); @@ -79,6 +81,9 @@ function ReadonlyCodeMirror({ tabindex: "0", "aria-label": `Source for ${values.filename}`, }), + // The preview is never focused, so the browser draws no selection of its own; the + // exact occurrence has to be painted by CodeMirror. + drawSelection(), EditorView.editable.of(false), languageCompartment.of( languageFor({ filename: values.filename, presentation: values.presentation }), @@ -112,13 +117,17 @@ function ReadonlyCodeMirror({ useEffect(() => { const view = viewRef.current; if (!view || !location.lineStart) return; - const line = Math.min(location.lineStart, view.state.doc.lines); - const from = view.state.doc.line(line).from; - view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) }); - }, [location.lineStart, navigationRevision]); + const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location); + view.dispatch({ + selection: { anchor: from, head: to }, + effects: EditorView.scrollIntoView(from, { y: "center" }), + }); + }, [content, location, navigationRevision]); + const changed = resolveWorkspaceFileSelection(content.replace(/\r\n?/g, "\n"), location).changed; return (
+ {changed ?
{t("shell.commandCenter.contentChanged")}
: null}
@@ -134,8 +143,11 @@ function languageFor(input: { : []; } +// The Find widget floats inside this frame, so it stays relative; the change notice is the +// only row that shares the column with the editor host. const FRAME_STYLE = { display: "flex", + flexDirection: "column", position: "relative", flex: 1, minHeight: 0, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 5e21b1a089..b1eddfaa74 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -73,6 +73,21 @@ export const ar: TranslationResources = { close: "إغلاق القائمة", }, commandCenter: { + contentTitle: "البحث في محتوى الملفات", + contentPlaceholder: "البحث في الملفات المحفوظة...", + contentUnavailable: "مساحة العمل غير متاحة", + contentUpdateHost: "حدّث هذا المضيف للبحث في محتوى الملفات", + contentEmpty: "البحث في الملفات المحفوظة", + contentBack: "العودة إلى النتائج", + contentPreview: "معاينة", + contentOpen: "فتح", + contentLoadingPreview: "جارٍ تحميل المعاينة...", + contentLimited: "النتائج محدودة — حدّد البحث أكثر", + contentScopeDetail: + "يبحث في ملفات UTF-8 المحفوظة، بما فيها المخفية وغير المتتبعة. تُطبق قواعد التجاهل. لا يشمل الروابط الرمزية والتعديلات غير المحفوظة.", + contentScope: "ملفات محفوظة حتى 1 MiB · تجاهل حالة الأحرف", + contentChanged: "تغيّر الملف — لم تعد المطابقة في هذا الموضع", + placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...", filePlaceholder: "البحث في الملفات...", searchingFiles: "جارٍ البحث في الملفات...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index acb8f94b74..407ae0bf1d 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -70,6 +70,21 @@ export const en = { close: "Close menu", }, commandCenter: { + contentTitle: "Search 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...", filePlaceholder: "Search files...", searchingFiles: "Searching files...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2cf3568a6d..8e156344ce 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -73,6 +73,21 @@ export const es: TranslationResources = { close: "Cerrar menú", }, commandCenter: { + contentTitle: "Buscar 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...", filePlaceholder: "Buscar archivos...", searchingFiles: "Buscando archivos...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 440a843968..cea53a4f87 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -74,6 +74,21 @@ export const fr: TranslationResources = { close: "Fermer le menu", }, commandCenter: { + contentTitle: "Rechercher dans les 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...", filePlaceholder: "Rechercher des fichiers...", searchingFiles: "Recherche de fichiers...", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index eef67e031d..b408a69fd6 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -73,6 +73,21 @@ export const ja: TranslationResources = { close: "メニューを閉じる", }, commandCenter: { + contentTitle: "ファイル内容を検索", + contentPlaceholder: "保存済みファイルの内容を検索...", + contentUnavailable: "ワークスペースを利用できません", + contentUpdateHost: "ファイル内容を検索するにはホストを更新してください", + contentEmpty: "保存済みファイルの内容を検索", + contentBack: "検索結果に戻る", + contentPreview: "プレビュー", + contentOpen: "開く", + contentLoadingPreview: "プレビューを読み込み中...", + contentLimited: "結果が上限に達しました — 検索を絞り込んでください", + contentScopeDetail: + "非表示・未追跡のファイルを含む保存済み UTF-8 ファイルを検索します。除外ルールに従い、シンボリックリンクと未保存の編集は検索しません。", + contentScope: "1 MiB 以下の保存済みファイル · 大文字小文字を区別しない", + contentChanged: "ファイルが変更され、この位置に一致する内容はありません", + placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...", filePlaceholder: "ファイルを検索...", searchingFiles: "ファイルを検索中...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index fbe8970665..d5eac90fb3 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -73,6 +73,21 @@ export const ko: TranslationResources = { close: "메뉴 닫기", }, commandCenter: { + contentTitle: "파일 내용 검색", + contentPlaceholder: "저장된 파일 내용 검색...", + contentUnavailable: "작업 공간을 사용할 수 없음", + contentUpdateHost: "파일 내용을 검색하려면 호스트를 업데이트하세요", + contentEmpty: "저장된 파일 내용 검색", + contentBack: "결과로 돌아가기", + contentPreview: "미리보기", + contentOpen: "열기", + contentLoadingPreview: "미리보기 로딩 중...", + contentLimited: "결과 제한 — 검색 범위를 좁히세요", + contentScopeDetail: + "숨김 파일과 추적되지 않은 파일을 포함한 저장된 UTF-8 파일을 검색합니다. 무시 규칙을 따르며 심볼릭 링크와 저장되지 않은 편집은 제외합니다.", + contentScope: "1 MiB 이하 저장된 파일 · 대소문자 구분 안 함", + contentChanged: "파일이 변경되어 이 위치에 일치하는 내용이 없습니다", + placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...", filePlaceholder: "파일 검색...", searchingFiles: "파일 검색 중...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b906b4d6e3..7ec42f2914 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -73,6 +73,21 @@ export const ptBR: TranslationResources = { close: "Fechar menu", }, commandCenter: { + contentTitle: "Buscar 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...", filePlaceholder: "Pesquisar arquivos...", searchingFiles: "Pesquisando arquivos...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 76b066154a..dcab559c2a 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -73,6 +73,21 @@ export const ru: TranslationResources = { close: "Закрыть меню", }, commandCenter: { + contentTitle: "Поиск по содержимому файлов", + contentPlaceholder: "Поиск в сохранённых файлах...", + contentUnavailable: "Рабочее пространство недоступно", + contentUpdateHost: "Обновите этот хост для поиска по содержимому", + contentEmpty: "Поиск в сохранённых файлах", + contentBack: "Назад к результатам", + contentPreview: "Предпросмотр", + contentOpen: "Открыть", + contentLoadingPreview: "Загрузка предпросмотра...", + contentLimited: "Результаты ограничены — уточните поиск", + contentScopeDetail: + "Поиск в сохранённых файлах UTF-8, включая скрытые и неотслеживаемые. Учитываются правила исключения. Символические ссылки и несохранённые изменения исключены.", + contentScope: "Сохранённые файлы до 1 МиБ · Без учёта регистра", + contentChanged: "Файл изменён — совпадения больше нет в этом месте", + placeholder: "Поиск команд, файлов, рабочих пространств и агентов...", filePlaceholder: "Поиск файлов...", searchingFiles: "Поиск файлов...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index cc3dc52587..abf7478509 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -73,6 +73,21 @@ export const zhCN: TranslationResources = { close: "关闭菜单", }, commandCenter: { + contentTitle: "搜索文件内容", + contentPlaceholder: "搜索已保存的文件内容...", + contentUnavailable: "工作区不可用", + contentUpdateHost: "请更新此主机以搜索文件内容", + contentEmpty: "搜索已保存的文件内容", + contentBack: "返回结果", + contentPreview: "预览", + contentOpen: "打开", + contentLoadingPreview: "正在加载预览...", + contentLimited: "结果已达上限 — 请缩小搜索范围", + contentScopeDetail: + "搜索已保存的 UTF-8 文件,包括隐藏和未跟踪的文件。遵循忽略规则,不搜索符号链接和未保存的编辑。", + contentScope: "不超过 1 MiB 的已保存文件 · 忽略大小写", + contentChanged: "文件已更改 — 此位置不再匹配", + placeholder: "搜索命令、文件、工作区和 Agent...", filePlaceholder: "搜索文件...", searchingFiles: "正在搜索文件...", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 1bf78d923f..8fb926eb68 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -46,6 +46,7 @@ export type KeyboardActionId = | "settings.toggle" | "command-center.toggle" | "command-center.files" + | "command-center.content" | "shortcuts.dialog.toggle" | "workspace.terminal.new" | "workspace.new" diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 02e23ba614..beca67864e 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -1196,3 +1196,20 @@ describe("direct new-tab target shortcuts", () => { ).toEqual([["ctrl", "shift", "H"]]); }); }); + +it("searches contents from terminal focus while preserving explicit Focus overrides", () => { + const event = { key: "F", code: "KeyF", metaKey: true, shiftKey: true }; + const context = { isMac: true, isDesktop: true, focusScope: "terminal" as const }; + expect(resolveShortcut({ event, context }).match?.action).toBe("command-center.content"); + expect(resolveShortcut({ event: { ...event, altKey: true }, context }).match?.action).toBe( + "view.toggle.focus", + ); + const bindings = buildEffectiveBindings({ "view-toggle-focus-cmd-shift-f-mac": "Cmd+Shift+F" }); + expect(resolveShortcut({ event, context, bindings }).match?.action).toBe("view.toggle.focus"); + const disabled = buildEffectiveBindings({ + "view-toggle-focus-cmd-shift-f-mac": UNASSIGNED_COMBO, + }); + expect( + resolveShortcut({ event: { ...event, altKey: true }, context, bindings: disabled }).match, + ).toBeNull(); +}); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index d18650a80a..882f016b33 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -156,6 +156,7 @@ export const SHORTCUT_HELP_ROW_ORDER: Record = { "toggle-right-sidebar": "settings.shortcuts.help.toggleRightSidebar", "toggle-both-sidebars": "settings.shortcuts.help.toggleBothSidebars", "toggle-settings": "settings.shortcuts.help.toggleSettings", + "search-workspace-content": "shell.commandCenter.contentTitle", "toggle-focus": "settings.shortcuts.help.toggleFocusMode", "cycle-theme": "settings.shortcuts.help.cycleTheme", "focus-message-input": "settings.shortcuts.help.focusMessageInput", @@ -1003,7 +1005,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "view-toggle-focus-cmd-shift-f-mac", action: "view.toggle.focus", - combo: "Cmd+Shift+F", + combo: "Cmd+Alt+Shift+F", when: { mac: true, commandCenter: false }, help: { id: "toggle-focus", @@ -1014,7 +1016,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ { id: "view-toggle-focus-ctrl-shift-f-non-mac", action: "view.toggle.focus", - combo: "Ctrl+Shift+F", + combo: "Ctrl+Alt+Shift+F", when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-focus", @@ -1023,6 +1025,21 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + { + id: "command-center-content-cmd-shift-f-mac", + action: "command-center.content", + combo: "Cmd+Shift+F", + when: { mac: true, commandCenter: false }, + help: { id: "search-workspace-content", section: "general", label: "Search file contents" }, + }, + { + id: "command-center-content-ctrl-shift-f-non-mac", + action: "command-center.content", + combo: "Ctrl+Shift+F", + when: { mac: false, commandCenter: false }, + help: { id: "search-workspace-content", section: "general", label: "Search file contents" }, + }, + // --- Theme cycling --- { id: "theme-cycle-cmd-shift-t-mac", diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index aa7679a7bf..6aa83fcf81 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -32,7 +32,7 @@ export type ShortcutAction = | { kind: "router-push"; route: string } | { kind: "open-project-picker" } | { kind: "callback"; name: ShortcutCallbackName } - | { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" } + | { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" | "content" } | { kind: "shortcuts-dialog-toggle"; nextOpen: boolean }; const NONE: ShortcutAction = { kind: "none" }; @@ -209,6 +209,10 @@ export function routeKeyboardShortcut( return routeSettingsToggle(ctx); case "command-center.toggle": return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen }; + case "command-center.content": + if (parseHostWorkspaceRouteFromPathname(ctx.pathname)) + return { kind: "command-center-toggle", nextOpen: true, scope: "content" }; + return dispatch({ id: "workspace.project.pick", scope: "workspace" }); case "command-center.files": if (parseHostWorkspaceRouteFromPathname(ctx.pathname)) { return { kind: "command-center-toggle", nextOpen: true, scope: "files" }; diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts index 0b0e2a516c..825eb08b51 100644 --- a/packages/app/src/stores/keyboard-shortcuts-store.ts +++ b/packages/app/src/stores/keyboard-shortcuts-store.ts @@ -3,7 +3,7 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts"; const SHORTCUT_BADGE_DELAY_MS = 150; -export type CommandCenterScope = "files" | null; +export type CommandCenterScope = "files" | "content" | null; interface KeyboardShortcutsState { commandCenterOpen: boolean; diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 974b8c6964..9281950eaf 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -207,6 +207,9 @@ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ path: z.string(), lineStart: z.number().int().positive().optional(), lineEnd: z.number().int().positive().optional(), + columnStart: z.number().int().positive().optional(), + columnEnd: z.number().int().positive().optional(), + expectedText: z.string().optional(), }), z.strictObject({ kind: z.literal("working_diff"), diff --git a/packages/app/src/workspace/file-open/index.test.ts b/packages/app/src/workspace/file-open/index.test.ts index d7d2dd3976..2c75963a40 100644 --- a/packages/app/src/workspace/file-open/index.test.ts +++ b/packages/app/src/workspace/file-open/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + resolveWorkspaceFileSelection, createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, resolveWorkspaceFilePaths, @@ -34,7 +35,7 @@ describe("normalizeWorkspaceFileLocation", () => { }); it("rejects empty paths", () => { - expect(normalizeWorkspaceFileLocation({ path: " " })).toBeNull(); + expect(normalizeWorkspaceFileLocation({ path: "" })).toBeNull(); }); }); @@ -183,3 +184,45 @@ describe("resolveWorkspaceFilePaths", () => { ).toBeNull(); }); }); + +it("selects exact UTF-16 columns and refuses a stale saved match", () => { + const location = { + path: "a.ts", + lineStart: 2, + columnStart: 5, + columnEnd: 11, + expectedText: "NEEDLE", + }; + expect(resolveWorkspaceFileSelection("first\né🙂 NEEDLE", location)).toEqual({ + from: 10, + to: 16, + changed: false, + }); + expect(resolveWorkspaceFileSelection("first\né🙂 change", location)).toEqual({ + from: 10, + to: 10, + changed: true, + }); + expect(workspaceFileLocationsEqual(location, { ...location, columnStart: 6 })).toBe(false); +}); + +it("does not select an identical occurrence on another line after the saved line disappears", () => { + expect( + resolveWorkspaceFileSelection("needle", { + path: "a.ts", + lineStart: 3, + columnStart: 1, + columnEnd: 7, + expectedText: "needle", + }), + ).toEqual({ from: 0, to: 0, changed: true }); +}); + +it("preserves literal filename and workspace whitespace through location and host paths", () => { + const location = { path: " leading.txt ", lineStart: 1 }; + expect(normalizeWorkspaceFileLocation(location)).toEqual(location); + expect(resolveWorkspaceFilePaths({ path: location.path, workspaceRoot: "/workspace " })).toEqual({ + absolutePath: "/workspace / leading.txt ", + relativePath: " leading.txt ", + }); +}); diff --git a/packages/app/src/workspace/file-open/index.ts b/packages/app/src/workspace/file-open/index.ts index f09d5bd81f..a777871638 100644 --- a/packages/app/src/workspace/file-open/index.ts +++ b/packages/app/src/workspace/file-open/index.ts @@ -4,8 +4,15 @@ export type OpenFileDisposition = "main" | "preferred" | "side"; export interface WorkspaceFileLocation { path: string; + /** One-based source line; CRLF, CR and LF each separate lines. */ lineStart?: number; lineEnd?: number; + /** One-based UTF-16 column on lineStart. */ + columnStart?: number; + /** Exclusive UTF-16 column on the same line. */ + columnEnd?: number; + /** Original saved occurrence; a mismatch reveals the location without selecting text. */ + expectedText?: string; } export type WorkspaceFileTabTarget = { kind: "file" } & WorkspaceFileLocation; @@ -22,16 +29,21 @@ export function normalizeWorkspaceFileLocation( return null; } - const path = location.path.trim().replace(/\\/g, "/"); + const path = location.path.replace(/\\/g, "/"); if (!path) { return null; } const lineStart = normalizeLineNumber(location.lineStart); const lineEnd = normalizeLineNumber(location.lineEnd); + const columnStart = normalizeLineNumber(location.columnStart); + const columnEnd = normalizeLineNumber(location.columnEnd); return { path, ...(lineStart ? { lineStart } : {}), + ...(lineStart && columnStart ? { columnStart } : {}), + ...(lineStart && columnEnd && columnEnd >= (columnStart ?? 1) ? { columnEnd } : {}), + ...(location.expectedText !== undefined ? { expectedText: location.expectedText } : {}), ...(lineStart && lineEnd && lineEnd >= lineStart ? { lineEnd } : {}), }; } @@ -41,7 +53,12 @@ export function workspaceFileLocationsEqual( right: WorkspaceFileLocation, ): boolean { return ( - left.path === right.path && left.lineStart === right.lineStart && left.lineEnd === right.lineEnd + left.path === right.path && + left.lineStart === right.lineStart && + left.lineEnd === right.lineEnd && + left.columnStart === right.columnStart && + left.columnEnd === right.columnEnd && + left.expectedText === right.expectedText ); } @@ -145,8 +162,8 @@ export function resolveWorkspaceFilePaths(input: { path: string; workspaceRoot: string; }): ResolvedWorkspaceFilePaths | null { - const filePath = input.path.trim().replace(/\\/g, "/"); - const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/")); + const filePath = input.path.replace(/\\/g, "/"); + const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.replace(/\\/g, "/")); if (!filePath || !workspaceRoot) { return null; } @@ -176,3 +193,26 @@ export function resolveWorkspaceFilePaths(input: { } return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath }; } + +/** Resolves a saved location without manufacturing a selection when its text has moved. */ +export function resolveWorkspaceFileSelection( + content: string, + location: WorkspaceFileLocation, +): { from: number; to: number; changed: boolean } { + const lines = content.split("\n"); + const index = Math.max(0, Math.min((location.lineStart ?? 1) - 1, lines.length - 1)); + const line = lines[index]; + const offset = lines.slice(0, index).reduce((sum, value) => sum + value.length + 1, 0); + const from = offset + Math.min((location.columnStart ?? 1) - 1, line.length); + const endIndex = Math.max(index, Math.min((location.lineEnd ?? index + 1) - 1, lines.length - 1)); + let to = from; + if (location.columnEnd !== undefined) to = offset + Math.min(location.columnEnd - 1, line.length); + else if (endIndex > index) + to = + lines.slice(0, endIndex).reduce((sum, value) => sum + value.length + 1, 0) + + lines[endIndex].length; + const changed = + location.expectedText !== undefined && + (index !== (location.lineStart ?? 1) - 1 || content.slice(from, to) !== location.expectedText); + return { from, to: changed ? from : to, changed }; +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index e22534da35..b423982da1 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,3 +1,4 @@ +import type { WorkspaceContentSearchResult } from "@getpaseo/protocol/messages"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import { @@ -4491,6 +4492,33 @@ export class DaemonClient { return payload.directory; } + async searchWorkspaceContent( + input: { cwd: string; query: string }, + options: { signal?: AbortSignal } = {}, + ): Promise { + if (options.signal?.aborted) + return { status: "error", code: "cancelled", message: "Search cancelled" }; + const requestId = this.createRequestId(); + const cancel = () => { + void this.sendCorrelatedSessionRequest({ + message: { type: "fs.content.cancel.request", searchRequestId: requestId }, + responseType: "fs.content.cancel.response", + }).catch(() => undefined); + }; + const response = this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "fs.content.search.request", ...input }, + responseType: "fs.content.search.response", + }); + options.signal?.addEventListener("abort", cancel, { once: true }); + if (options.signal?.aborted) cancel(); + try { + return (await response).result; + } finally { + options.signal?.removeEventListener("abort", cancel); + } + } + async readFile( cwd: string, path: string, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7068cdc0bf..45685e0e76 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,4 @@ +export type { FileReadResult } from "./daemon-client.js"; import type { DaemonClientConfig } from "./daemon-client.js"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import type { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index ed36eeb1ad..7afcffd563 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2688,6 +2688,50 @@ export const FileVersionSchema = z.discriminatedUnion("status", [ }), ]); +export const WorkspaceContentMatchSchema = z.object({ + path: z.string(), + line: z.number().int().positive(), + columnStart: z.number().int().positive(), + columnEnd: z.number().int().positive(), + text: z.string(), + snippet: z.string(), + snippetMatchStart: z.number().int().nonnegative(), + snippetMatchEnd: z.number().int().nonnegative(), +}); +export const WorkspaceContentSearchResultSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("ok"), + matches: z.array(WorkspaceContentMatchSchema), + limited: z.boolean(), + }), + z.object({ + status: z.literal("error"), + code: z.enum(["missing_rg", "timeout", "invalid_query", "unavailable", "busy", "cancelled"]), + message: z.string(), + }), +]); +export const WorkspaceContentSearchRequestSchema = z.object({ + type: z.literal("fs.content.search.request"), + requestId: z.string(), + cwd: z.string(), + query: z.string(), +}); +export const WorkspaceContentCancelRequestSchema = z.object({ + type: z.literal("fs.content.cancel.request"), + requestId: z.string(), + searchRequestId: z.string(), +}); +export const WorkspaceContentSearchResponseSchema = z.object({ + type: z.literal("fs.content.search.response"), + payload: z.object({ requestId: z.string(), result: WorkspaceContentSearchResultSchema }), +}); +export const WorkspaceContentCancelResponseSchema = z.object({ + type: z.literal("fs.content.cancel.response"), + payload: z.object({ requestId: z.string(), searchRequestId: z.string() }), +}); +export type WorkspaceContentMatch = z.infer; +export type WorkspaceContentSearchResult = z.infer; + export const FileSubscribeRequestSchema = z.object({ type: z.literal("fs.file.subscribe.request"), cwd: z.string(), @@ -3211,6 +3255,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ WorkspaceClearAttentionRequestSchema, WorkspaceMarkUnreadRequestSchema, FileExplorerRequestSchema, + WorkspaceContentSearchRequestSchema, + WorkspaceContentCancelRequestSchema, FileSubscribeRequestSchema, FileUnsubscribeRequestSchema, FileWriteRequestSchema, @@ -3516,6 +3562,7 @@ export const ServerInfoStatusPayloadSchema = z workspaceRecovery: z.boolean().optional(), // COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0. workspaceFileEditing: z.boolean().optional(), + workspaceContentSearch: z.boolean().optional(), // COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98. providerUsageList: z.boolean().optional(), // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. @@ -6617,6 +6664,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ PaseoWorktreeArchiveResponseSchema, CreatePaseoWorktreeResponseSchema, FileExplorerResponseSchema, + WorkspaceContentSearchResponseSchema, + WorkspaceContentCancelResponseSchema, FileSubscribeResponseSchema, FileUnsubscribeResponseSchema, FileWriteResponseSchema, diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index ec6c4db2dd..38430a00b7 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -81,6 +81,8 @@ const INBOUND_PERMISSION = { fetch_workspaces_request: ["workspace.read", "hub.execute"], "file.upload.request": "workspace.write", file_download_token_request: "workspace.read", + "fs.content.search.request": "workspace.read", + "fs.content.cancel.request": "workspace.read", file_explorer_request: "workspace.read", "forge.search.request": "workspace.read", "fs.entry.create.request": "workspace.write", @@ -292,6 +294,8 @@ const OUTBOUND_PERMISSION = { fetch_workspaces_response: ["workspace.read", "hub.execute"], "file.upload.response": "workspace.write", file_download_token_response: "workspace.read", + "fs.content.search.response": "workspace.read", + "fs.content.cancel.response": "workspace.read", file_explorer_response: "workspace.read", "forge.search.response": "workspace.read", "fs.entry.create.response": "workspace.write", diff --git a/packages/server/src/server/daemon-e2e/workspace-content-search.e2e.test.ts b/packages/server/src/server/daemon-e2e/workspace-content-search.e2e.test.ts new file mode 100644 index 0000000000..9f134fb581 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/workspace-content-search.e2e.test.ts @@ -0,0 +1,39 @@ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { expect, test } from "vitest"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +test("real client searches saved contents, cancels host work and can search again", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "paseo-content-rpc-")); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await writeFile(path.join(cwd, "a.ts"), "const original = 'NEEDLE';\n"); + await client.connect(); + await client.fetchAgents(); + const result = await client.searchWorkspaceContent({ cwd, query: "needle" }); + expect( + result.status === "ok" && result.matches.map((match) => [match.path, match.text]), + ).toEqual([["a.ts", "NEEDLE"]]); + const controller = new AbortController(); + const searching = client.searchWorkspaceContent( + { cwd, query: "absent" }, + { signal: controller.signal }, + ); + controller.abort(); + expect(await searching).toMatchObject({ status: "error", code: "cancelled" }); + expect(await client.searchWorkspaceContent({ cwd, query: "missing" })).toEqual({ + status: "ok", + matches: [], + limited: false, + }); + const preview = await client.readFile(cwd, "a.ts", undefined, 1_048_576); + expect(new TextDecoder().decode(preview.bytes)).toBe("const original = 'NEEDLE';\n"); + } finally { + await client.close(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 30000); diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index ea22ac24b9..83430aefaa 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -592,3 +592,44 @@ describe("file explorer service", () => { } }); }); + +it("enforces the caller byte ceiling at the opened file for inline and streaming reads", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-read-budget-")); + try { + await writeFile(path.join(root, "growing.txt"), "small"); + const budget = (await stat(path.join(root, "growing.txt"))).size; + await appendFile(path.join(root, "growing.txt"), " grew after precheck"); + await expect( + readExplorerFile({ root, relativePath: "growing.txt", maxBytes: budget }), + ).rejects.toThrow("File is too large to display"); + await expect( + streamExplorerFile({ root, relativePath: "growing.txt", maxBytes: budget }, async () => { + throw new Error("Must reject before delivering content"); + }), + ).rejects.toThrow("File is too large to display"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it("preserves literal whitespace in existing file identities through read and write", async () => { + const root = await createTempDir("paseo-literal-path-"); + try { + await writeFile(path.join(root, " leading.txt"), "needle"); + await writeFile(path.join(root, "leading.txt"), "different file"); + const file = await readExplorerFile({ root, relativePath: " leading.txt" }); + expect(file.content).toBe("needle"); + const written = await writeExplorerFile({ + root, + relativePath: " leading.txt", + content: "updated", + expectedModifiedAt: file.modifiedAt, + expectedRevision: file.revision, + }); + expect(written.status).toBe("written"); + expect(await readFile(path.join(root, " leading.txt"), "utf8")).toBe("updated"); + expect(await readFile(path.join(root, "leading.txt"), "utf8")).toBe("different file"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 5aa4efe974..8e4b93a060 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -15,6 +15,7 @@ export interface ListDirectoryParams { } export interface ReadFileParams { + maxBytes?: number; root: string; relativePath: string; } @@ -193,8 +194,9 @@ export async function listDirectoryEntries({ export async function readExplorerFile({ root, relativePath, + maxBytes, }: ReadFileParams): Promise { - const file = await readExplorerFileBytes({ root, relativePath }); + const file = await readExplorerFileBytes({ root, relativePath, maxBytes }); if (file.kind === "image") { return { @@ -236,6 +238,7 @@ export async function readExplorerFile({ export async function readExplorerFileBytes({ root, relativePath, + maxBytes, }: ReadFileParams): Promise { const filePath = await resolveScopedPath({ root, relativePath }); const handle = await openFileForRead(filePath.resolvedPath); @@ -247,6 +250,8 @@ export async function readExplorerFileBytes({ throw new Error("Requested path is not a file"); } + if (maxBytes !== undefined && stats.size > BigInt(maxBytes)) + throw new Error("File is too large to display"); const ext = path.extname(filePath.resolvedPath).toLowerCase(); const basePayload = { path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }), @@ -255,7 +260,10 @@ export async function readExplorerFileBytes({ revision: fileRevision(stats), }; - const buffer = await handle.readFile(); + const chunks: Buffer[] = []; + for await (const chunk of readFileHandleChunks(handle, Number(stats.size), fileRevision(stats))) + chunks.push(Buffer.from(chunk)); + const buffer = Buffer.concat(chunks); if (ext in IMAGE_MIME_TYPES) { return { ...basePayload, @@ -289,7 +297,7 @@ export async function readExplorerFileBytes({ } export async function streamExplorerFile( - { root, relativePath }: ReadFileParams, + { root, relativePath, maxBytes }: ReadFileParams, consume: (file: FileExplorerFileStream) => Promise, ): Promise { const filePath = await resolveScopedPath({ root, relativePath }); @@ -301,6 +309,8 @@ export async function streamExplorerFile( throw new Error("Requested path is not a file"); } + if (maxBytes !== undefined && stats.size > BigInt(maxBytes)) + throw new Error("File is too large to display"); const advertisedSize = Number(stats.size); const advertisedRevision = fileRevision(stats); const ext = path.extname(filePath.resolvedPath).toLowerCase(); diff --git a/packages/server/src/server/path-utils.ts b/packages/server/src/server/path-utils.ts index f85c092f96..bb3c12436e 100644 --- a/packages/server/src/server/path-utils.ts +++ b/packages/server/src/server/path-utils.ts @@ -12,19 +12,17 @@ function hasHomePrefix(value: string): boolean { } export function expandUserPath(value: string): string { - const trimmed = value.trim(); - if (hasHomePrefix(trimmed)) { - return resolve(homedir(), trimmed.slice(2)); + if (hasHomePrefix(value)) { + return resolve(homedir(), value.slice(2)); } - return resolve(trimmed); + return resolve(value); } export function resolvePathFromBase(baseCwd: string, requestedPath: string): string { - const trimmed = requestedPath.trim(); - if (hasHomePrefix(trimmed) || isAbsolute(trimmed)) { - return expandUserPath(trimmed); + if (hasHomePrefix(requestedPath) || isAbsolute(requestedPath)) { + return expandUserPath(requestedPath); } - return resolve(baseCwd, trimmed); + return resolve(baseCwd, requestedPath); } export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c1e149ee5e..263d8bf8c7 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2624,6 +2624,11 @@ export class Session { source?: object, ): Promise | undefined { switch (msg.type) { + case "fs.content.search.request": + return this.workspaceFilesSession.handleContentSearch(msg); + case "fs.content.cancel.request": + this.workspaceFilesSession.handleContentCancel(msg); + return; case "file_explorer_request": return this.workspaceFilesSession.handleFileExplorerRequest(msg, source); case "fs.file.subscribe.request": diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index 0f833479c2..5e54008902 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -1,3 +1,4 @@ +import { searchWorkspaceContent } from "../../workspace-content-search/index.js"; import type pino from "pino"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { @@ -63,6 +64,7 @@ export interface WorkspaceFilesSessionOptions { * the whole concern. */ export class WorkspaceFilesSession { + private readonly searches = new Map(); private readonly host: WorkspaceFilesSessionHost; private readonly downloadTokenStore: DownloadTokenStore; private readonly logger: pino.Logger; @@ -78,6 +80,50 @@ export class WorkspaceFilesSession { this.fileObserver = options.fileObserver ?? workspaceFileObserver; } + async handleContentSearch( + request: Extract, + ): Promise { + if (this.searches.size >= 2 || this.searches.has(request.requestId)) { + this.host.emit({ + type: "fs.content.search.response", + payload: { + requestId: request.requestId, + result: { + status: "error", + code: "busy", + message: "Another search is still stopping — retry", + }, + }, + }); + return; + } + const controller = new AbortController(); + this.searches.set(request.requestId, controller); + try { + const result = await searchWorkspaceContent({ + cwd: request.cwd, + query: request.query, + signal: controller.signal, + }); + this.host.emit({ + type: "fs.content.search.response", + payload: { requestId: request.requestId, result }, + }); + } finally { + this.searches.delete(request.requestId); + } + } + + handleContentCancel( + request: Extract, + ): void { + this.searches.get(request.searchRequestId)?.abort(); + this.host.emit({ + type: "fs.content.cancel.response", + payload: { requestId: request.requestId, searchRequestId: request.searchRequestId }, + }); + } + async handleFileSubscribeRequest(request: FileSubscribeRequest): Promise { this.fileSubscriptions.get(request.subscriptionId)?.(); try { @@ -214,13 +260,14 @@ export class WorkspaceFilesSession { } dispose(): void { + for (const controller of this.searches.values()) controller.abort(); for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe(); this.fileSubscriptions.clear(); } async handleFileExplorerRequest(request: FileExplorerRequest, source?: object): Promise { const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request; - const cwd = workspaceCwd.trim(); + const cwd = workspaceCwd; if (!cwd) { this.host.emit( { @@ -270,41 +317,45 @@ export class WorkspaceFilesSession { } } if (request.acceptBinary && this.host.hasBinaryChannel()) { - await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => { - await this.host.emitBinary( - encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileBegin, - requestId, - metadata: { - mime: file.mimeType, - size: file.size, - encoding: file.encoding, - modifiedAt: file.modifiedAt, - revision: file.revision, - }, - }), - source, - ); - for await (const chunk of file.chunks) { + await streamExplorerFile( + { root: cwd, relativePath: requestedPath, maxBytes: request.maxBytes }, + async (file) => { await this.host.emitBinary( encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileChunk, + opcode: FileTransferOpcode.FileBegin, requestId, - payload: chunk, + metadata: { + mime: file.mimeType, + size: file.size, + encoding: file.encoding, + modifiedAt: file.modifiedAt, + revision: file.revision, + }, }), source, ); - } - await this.host.emitBinary( - encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileEnd, - requestId, - }), - source, - ); - }); + for await (const chunk of file.chunks) { + await this.host.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId, + payload: chunk, + }), + source, + ); + } + await this.host.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileEnd, + requestId, + }), + source, + ); + }, + ); } else { const file = await readExplorerFile({ + maxBytes: request.maxBytes, root: cwd, relativePath: requestedPath, }); @@ -391,7 +442,7 @@ export class WorkspaceFilesSession { async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise { const { cwd: workspaceCwd, path: requestedPath, requestId } = request; - const cwd = workspaceCwd.trim(); + const cwd = workspaceCwd; if (!cwd) { this.host.emit({ type: "file_download_token_response", diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 30fce35b13..535fb727cc 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1712,6 +1712,7 @@ export class VoiceAssistantWebSocketServer { workspaceRecovery: true, // COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0. workspaceFileEditing: true, + workspaceContentSearch: true, // COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98. providerUsageList: true, // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. diff --git a/packages/server/src/server/workspace-content-search/index.test.ts b/packages/server/src/server/workspace-content-search/index.test.ts new file mode 100644 index 0000000000..2837435efd --- /dev/null +++ b/packages/server/src/server/workspace-content-search/index.test.ts @@ -0,0 +1,157 @@ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { searchWorkspaceContent } from "./index.js"; + +const roots: string[] = []; +async function workspace(files: Record) { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-content-search-")); + roots.push(root); + for (const [name, text] of Object.entries(files)) await writeFile(path.join(root, name), text); + return root; +} +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +test("searches saved untracked text literally, ignoring case and ignore rules in a non-Git workspace", async () => { + const cwd = await workspace({ + "a.ts": "é🙂 NEEDLE needle\r\n", + "b.ts": "a.b AXB", + ".hidden": "Needle", + ".gitignore": "ignored\n", + ignored: "needle", + }); + const result = await searchWorkspaceContent({ cwd, query: "needle" }); + expect(result).toEqual({ + status: "ok", + limited: false, + matches: [ + { + path: ".hidden", + line: 1, + columnStart: 1, + columnEnd: 7, + text: "Needle", + snippet: "Needle", + snippetMatchStart: 0, + snippetMatchEnd: 6, + }, + { + path: "a.ts", + line: 1, + columnStart: 5, + columnEnd: 11, + text: "NEEDLE", + snippet: "é🙂 NEEDLE needle", + snippetMatchStart: 4, + snippetMatchEnd: 10, + }, + { + path: "a.ts", + line: 1, + columnStart: 12, + columnEnd: 18, + text: "needle", + snippet: "é🙂 NEEDLE needle", + snippetMatchStart: 11, + snippetMatchEnd: 17, + }, + ], + }); + const literal = await searchWorkspaceContent({ cwd, query: "a.b" }); + expect(literal.status === "ok" && literal.matches.map((m) => m.text)).toEqual(["a.b"]); +}); + +test("reports missing rg, empty search, cancellation and file/result limits", async () => { + const cwd = await workspace({ + "many.txt": "needle\n".repeat(250), + oversized: "needle" + " ".repeat(1_048_576), + }); + const result = await searchWorkspaceContent({ cwd, query: "needle" }); + expect(result.status === "ok" && [result.matches.length, result.limited]).toEqual([200, true]); + expect(await searchWorkspaceContent({ cwd, query: "" })).toEqual({ + status: "ok", + matches: [], + limited: false, + }); + expect(await searchWorkspaceContent({ cwd, query: "x" }, { env: { PATH: "" } })).toMatchObject({ + status: "error", + code: "missing_rg", + }); + expect( + await searchWorkspaceContent({ cwd, query: "x", signal: AbortSignal.abort() }), + ).toMatchObject({ status: "error", code: "cancelled" }); +}); + +test("preserves Unicode case-fold matches and BOM offsets, rejects binary files and bounds long records", async () => { + const cwd = await workspace({ + unicode: "\uFEFFé🙂 K k K ſ S s σ Σ ς\r\n", + binary: "needle\0binary", + long: "x".repeat(1_040_000) + "needle", + }); + const kelvin = await searchWorkspaceContent({ cwd, query: "k" }); + expect(kelvin.status === "ok" && kelvin.matches.map((m) => [m.text, m.columnStart])).toEqual([ + ["K", 5], + ["k", 7], + ["K", 9], + ]); + const sigma = await searchWorkspaceContent({ cwd, query: "σ" }); + expect(sigma.status === "ok" && sigma.matches.map((m) => m.text)).toEqual(["σ", "Σ", "ς"]); + const binary = await searchWorkspaceContent({ cwd, query: "needle" }); + expect(binary.status === "ok" && binary.matches.map((m) => m.path)).toEqual(["long"]); +}); + +test("bounds escaped output and reports timeouts and invalid single-line queries", async () => { + const cwd = await workspace({ escaped: "\t".repeat(600_000) + "needle" }); + expect(await searchWorkspaceContent({ cwd, query: "needle" })).toMatchObject({ + status: "ok", + limited: true, + }); + expect(await searchWorkspaceContent({ cwd, query: "x\ny" })).toMatchObject({ + status: "error", + code: "invalid_query", + }); + expect(await searchWorkspaceContent({ cwd, query: "needle" }, { timeoutMs: 0 })).toMatchObject({ + status: "error", + code: "timeout", + }); +}); + +test("uses source lines across CR-only and mixed separators, including unmatched preceding lines", async () => { + const cwd = await workspace({ + "cr.txt": "first\rneedle", + "mixed.txt": "unmatched\rprefix\nfirst\r🙂 NEEDLE\r\nend", + }); + const result = await searchWorkspaceContent({ cwd, query: "needle" }); + expect( + result.status === "ok" && + result.matches.map(({ path: filePath, line, columnStart, columnEnd, text }) => ({ + path: filePath, + line, + columnStart, + columnEnd, + text, + })), + ).toEqual([ + { path: "cr.txt", line: 2, columnStart: 1, columnEnd: 7, text: "needle" }, + { path: "mixed.txt", line: 4, columnStart: 4, columnEnd: 10, text: "NEEDLE" }, + ]); +}); + +test("validates UTF-8 beyond the matching record and preserves an embedded BOM character", async () => { + const cwd = await workspace({ "valid.txt": "prefix \uFEFFneedle" }); + await writeFile( + path.join(cwd, "invalid.txt"), + Buffer.from([110, 101, 101, 100, 108, 101, 10, 255]), + ); + const invalid = await searchWorkspaceContent({ cwd, query: "needle" }); + expect(invalid.status === "ok" && invalid.matches.map((match) => match.path)).toEqual([ + "valid.txt", + ]); + const embedded = await searchWorkspaceContent({ cwd, query: "\uFEFFneedle" }); + expect( + embedded.status === "ok" && embedded.matches.map((match) => [match.text, match.columnStart]), + ).toEqual([["\uFEFFneedle", 8]]); +}); diff --git a/packages/server/src/server/workspace-content-search/index.ts b/packages/server/src/server/workspace-content-search/index.ts new file mode 100644 index 0000000000..934f43133d --- /dev/null +++ b/packages/server/src/server/workspace-content-search/index.ts @@ -0,0 +1,268 @@ +import { stat } from "node:fs/promises"; +import type { ChildProcess } from "node:child_process"; +import type { + WorkspaceContentMatch, + WorkspaceContentSearchResult, +} from "@getpaseo/protocol/messages"; +import { readExplorerFileBytes } from "../file-explorer/service.js"; +import { spawnProcess } from "../../utils/spawn.js"; + +const MAX_RESULTS = 200; +const MAX_FILE_BYTES = 1_048_576; +const MAX_OUTPUT_BYTES = 4 * MAX_FILE_BYTES; + +/** Searches the host's saved files; owns rg and every resource it allocates. */ +export async function searchWorkspaceContent( + input: { cwd: string; query: string; signal?: AbortSignal }, + options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}, +): Promise { + if (input.signal?.aborted) return failure("cancelled", "Search cancelled"); + if ( + Buffer.byteLength(input.query) > 1024 || + ["\0", "\r", "\n"].some((character) => input.query.includes(character)) + ) { + return failure("invalid_query", "Search text must be one line, up to 1,024 bytes"); + } + if (!input.query) return { status: "ok", matches: [], limited: false }; + try { + if (!(await stat(input.cwd)).isDirectory()) + return failure("unavailable", "Workspace directory is unavailable"); + } catch { + return failure("unavailable", "Workspace directory is unavailable"); + } + let child: ChildProcess; + try { + child = spawnProcess( + process.platform === "win32" ? "rg.exe" : "rg", + [ + "--no-config", + "--json", + "--fixed-strings", + "--ignore-case", + "--hidden", + "--no-require-git", + "--glob", + "!.git", + "--max-filesize", + String(MAX_FILE_BYTES), + "--sort", + "path", + "--encoding", + "none", + "--", + input.query, + ".", + ], + { cwd: input.cwd, shell: false, env: options.env, stdio: ["ignore", "pipe", "pipe"] }, + ); + } catch (cause) { + return spawnFailure(cause); + } + return new Promise((resolve) => { + const matches: RgMatch[] = []; + let pending: RgMatch[] = []; + let pendingPath = ""; + let buffer = Buffer.alloc(0); + let outputBytes = 0; + let stderr = ""; + let limited = false; + let stopped = false; + let error: WorkspaceContentSearchResult | null = null; + let killTimer: ReturnType | undefined; + const stop = () => { + if (stopped) return; + stopped = true; + child.kill(); + killTimer = setTimeout(() => child.kill("SIGKILL"), 250); + killTimer.unref(); + }; + const abort = () => { + error = failure("cancelled", "Search cancelled"); + stop(); + }; + const timeout = setTimeout(() => { + error = failure("timeout", "Search timed out — refine your query or retry"); + stop(); + }, options.timeoutMs ?? 5000); + input.signal?.addEventListener("abort", abort, { once: true }); + if (input.signal?.aborted) abort(); + child.on("error", (cause) => { + error ??= spawnFailure(cause); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr = (stderr + chunk.toString("utf8")).slice(0, 8192); + }); + child.stdout?.on("data", (chunk: Buffer) => { + if (stopped) return; + outputBytes += chunk.byteLength; + if (outputBytes > MAX_OUTPUT_BYTES) { + limited = true; + stop(); + return; + } + buffer = Buffer.concat([buffer, chunk]); + let end: number; + while ((end = buffer.indexOf(10)) !== -1) { + if (stopped) break; + const record = buffer.subarray(0, end); + buffer = buffer.subarray(end + 1); + if (record.byteLength > MAX_FILE_BYTES) { + limited = true; + stop(); + break; + } + try { + const event = JSON.parse(record.toString("utf8")) as RgEvent; + if (event.type === "begin") { + pending = []; + pendingPath = event.data.path?.text?.replace(/^\.\//, "") ?? ""; + } else if (event.type === "match" && pendingPath) { + pending.push( + ...readMatches( + event.data, + pendingPath, + MAX_RESULTS + 1 - matches.length - pending.length, + ), + ); + } else if (event.type === "end") { + // rg may emit matches before discovering a NUL byte later in the file. + if (event.data.binary_offset == null) matches.push(...pending); + pending = []; + if (matches.length > MAX_RESULTS) { + limited = true; + stop(); + } + } + } catch { + error = failure("unavailable", "Could not read ripgrep search results"); + stop(); + } + } + if (buffer.byteLength > MAX_FILE_BYTES) { + limited = true; + stop(); + } + }); + const cleanup = () => { + for (const timer of [timeout, killTimer]) clearTimeout(timer); + input.signal?.removeEventListener("abort", abort); + }; + child.once("close", async (code) => { + if (!error && !limited && code !== 0 && code !== 1) + error = failure("unavailable", stderr.trim() || "File search failed"); + let located: WorkspaceContentMatch[] = []; + try { + if (!error) + located = await locateMatches(input.cwd, matches.slice(0, MAX_RESULTS), () => !!error); + } catch { + error ??= failure("unavailable", "Files changed while searching — retry"); + } + cleanup(); + resolve(error ?? { status: "ok", matches: located, limited }); + }); + }); +} + +interface RgEvent { + type: string; + data: { + path?: { text?: string }; + lines?: { text?: string }; + line_number?: number; + submatches?: { start: number; end: number }[]; + binary_offset?: number | null; + absolute_offset?: number; + }; +} + +function readMatches(data: RgEvent["data"], path: string, remaining: number): RgMatch[] { + if (!data.lines?.text || !data.line_number || remaining <= 0) return []; + const raw = Buffer.from(data.lines.text); + return (data.submatches ?? []).slice(0, remaining).map(({ start, end }) => ({ + path, + byteOffset: (data.absolute_offset ?? 0) + start, + text: raw.subarray(start, end).toString("utf8"), + })); +} + +function failure( + code: Extract["code"], + message: string, +): WorkspaceContentSearchResult { + return { status: "error", code, message }; +} +function spawnFailure(cause: unknown): WorkspaceContentSearchResult { + return cause instanceof Error && "code" in cause && cause.code === "ENOENT" + ? failure( + "missing_rg", + "Install ripgrep on this host and make rg available on its PATH, then retry", + ) + : failure( + "unavailable", + cause instanceof Error ? cause.message : "Could not start file search", + ); +} + +interface RgMatch { + path: string; + text: string; + byteOffset: number; +} + +/** rg counts LF records; source views count CR, CRLF and LF lines. Read each matched + * file once through the bounded/revision-checked producer to translate original byte + * offsets. A file changed during the search must not manufacture an occurrence. */ +async function locateMatches( + cwd: string, + matches: RgMatch[], + stopped: () => boolean, +): Promise { + const located: WorkspaceContentMatch[] = []; + let path = ""; + let bytes: Uint8Array = new Uint8Array(); + let valid = false; + for (const { byteOffset, ...match } of matches) { + if (stopped()) break; + if (path !== match.path) { + path = match.path; + const file = await readExplorerFileBytes({ + root: cwd, + relativePath: path, + maxBytes: MAX_FILE_BYTES, + }); + bytes = file.bytes; + try { + new TextDecoder("utf-8", { fatal: true }).decode(bytes); + valid = !bytes.includes(0); + } catch { + valid = false; + } + } + if (!valid) continue; + const end = byteOffset + Buffer.byteLength(match.text); + if ( + new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes.subarray(byteOffset, end)) !== + match.text + ) + continue; + const prefix = new TextDecoder().decode(bytes.subarray(0, byteOffset)).split(/\r\n|\r|\n/); + const column = prefix[prefix.length - 1].length; + const line = + prefix[prefix.length - 1] + + new TextDecoder("utf-8", { ignoreBOM: true }) + .decode(bytes.subarray(byteOffset)) + .split(/\r\n|\r|\n/, 1)[0]; + const snippetStart = Math.max(0, column - 80); + const snippet = line.slice(snippetStart, snippetStart + 240); + located.push({ + ...match, + line: prefix.length, + columnStart: column + 1, + columnEnd: column + match.text.length + 1, + snippet, + snippetMatchStart: column - snippetStart, + snippetMatchEnd: Math.min(column + match.text.length - snippetStart, snippet.length), + }); + } + return located; +}