Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 5c581c2486 fix(server): convert content-search coordinates in one pass per file
Translating ripgrep byte offsets by decoding and splitting the whole prefix
and suffix for every occurrence costs the file per match. A 1,001,400-byte
file of a million empty lines followed by 200 matches blocked the host for
5,888ms and then answered "ok", 888ms after its own five-second budget.

A single forward cursor per file answers every occurrence, decoding each
needed line once and advancing the UTF-16 column from the previous match on
that line. Byte bounds, saved-revision validation, CR/CRLF/LF lines, BOM
handling, columns and original match text are unchanged.

Conversion also checks the operation deadline directly. The timer alone
cannot end an operation that overruns inside synchronous conversion, because
cleanup clears it before it can run.

Also scope the Windows skip in the content-search browser spec to the
literal-backslash fixture. At file scope it annotated the whole suite and
disabled all 12 journeys on Windows.
2026-09-10 15:17:14 +02:00
Mohamed Boudra 4e9dbc7f54 fix(app): host the desktop Command Center in the shared overlay root
A result row's exact path belongs in a tooltip: a path is often wider than the
result column, and two files can share both base name and nearest parent, so
no row label can carry it. The app's Tooltip could not serve that, and the
reason was the Command Center itself.

Desktop web rendered the panel through React Native Web's Modal, which paints
in a plane of its own above the shared overlay root. A tooltip portals into
that root, so it could never cover the panel however high its own z-index.
adaptive-modal-sheet already hosts desktop modals in the overlay root, which
is what docs/floating-panels.md prescribes; the Command Center now does the
same and native keeps the Modal. Its existing layer, focus registration,
backdrop and Escape handling are unchanged.

Proving it needed care. elementFromPoint reports the row either way, because a
tooltip is deliberately pointer-transparent, and sampling a pixel proves
nothing when the tooltip surface and the panel are both white. What settles it
is whether the pixels inside the tooltip's own rectangle change when it opens:
they do not under the Modal, and they do in the overlay root.

Replaces the hover text swap added in the previous commit, which only widened
the label inside a fixed-width column and still elided a genuinely long path.

Desktop web loses the Modal's fade-in; the panel now appears immediately.
2026-09-10 14:44:59 +02:00
Mohamed Boudra c33c1d0855 fix(app): spell out a result's path when its label cannot
A row shows the file name and its nearest parent, which is not always enough:
packages/app/src/utils/index.ts and packages/server/src/utils/index.ts render
identically. Pointing at a row, or selecting it, now replaces the elided
parent with every directory above the file, so a pointer user reads the exact
workspace-relative path without the preview getting a header back. At rest the
column stays scannable. The accessible name was already exact and is unchanged.

The app's Tooltip was tried first and cannot serve this surface: it portals
into the shared overlay root, which is pinned to the browser/overlay plane at
z-index 1, while the Command Center renders through React Native Web's Modal
at z-index 9999. The tooltip mounts, measures and reports itself visible, but
elementFromPoint at its own coordinates returns the row behind it, so a reader
never sees it. Every other desktop overlay in the app portals into the overlay
root instead of using RN's Modal, which is what docs/floating-panels.md
prescribes; moving the Command Center onto it would fix this for every scope
and is a larger change than one row affordance.
2026-09-10 14:17:25 +02:00
Mohamed Boudra 770648327f fix(app): finish desktop content search
Reopening the same occurrence left the pane where the reader had scrolled it.
A file target is identity-stable, so the second activation reused the tab and
looked like a no-op; the existing openers avoid this by asking for navigation
after opening, which the search callback had assembled around. The per-tab
navigation revision now lives with the tabs in the layout store, one
open-a-workspace-file command carries the whole recipe, and the explorer,
filename search and content search all go through it. The editor instance,
its dirty buffer and its history are untouched.

Result rows now read as three things rather than one. The match is foreground
on the highlight, the rest of the matched line is its context, and the
location is context for that. Rows are 44px instead of 56px, so more
occurrences fit without shrinking the text.

Row identity was the worst of it: a flat occurrence list is full of repeated
base names, and an end-truncated path threw away exactly the parents that
tell them apart, so four different index.tsx rows looked identical. The row
keeps the file name and its closest parent, marks the elided head, and pins
the name so only the parent may shrink.

The desktop preview drops its header. The selected row names the file and
Enter opens it, so a repeated title and an Open button were duplicate chrome.
The compact flow pages away from the list and keeps both.

The daemon end-to-end assertion expected an empty result without the
per-file ceiling the host reports, so it failed before reaching its file-read
step. Fixtures that can only exist on POSIX — a file named with a backslash,
a PATH built from which and symlinks — are skipped on Windows, and
toWorkspaceRelativePath gets separator coverage that runs everywhere.

Removes the native row-geometry estimator added earlier in this branch. It
predicted platform text layout from character counts and never agreed with
what Android drew. Native source returns to the behaviour it had before.
2026-09-10 14:00:09 +02:00
Mohamed Boudra 2a2362f5cf fix(app): reveal a deep source match on native
Selecting a match 1,201 lines into an ordinary TypeScript file showed line
1,103 on an Android device. The match was never on screen, so the preview and
Open flow could not deliver the occurrence they promised.

The cause, measured rather than assumed. The list was told every row is one
line of code tall. Probed on the device, a plain row is 17.52dp and a wrapped
row is 140.19dp, because a row is as tall as the text needs and ordinary source
lines wrap on a phone. Every wrapped line above the match therefore shrank the
computed offset, and the list scrolled to a fraction of the real distance.

Row geometry is now derived instead of assumed: one visual line is the reader's
code size rounded the way the device rounds it, and a line occupies as many
visual lines as it wraps onto, counted the way the platform wraps — greedily at
spaces, mid-word only for a word too long for a line. The column count comes
from measuring one monospace character at the current code size, so it follows
the Appearance setting rather than a constant.

Two smaller things fell out. The list renders only once that measurement
exists, because scrolling a list still sized for a provisional geometry gets
clamped to the shorter content and lands short. And the match is revealed near
the top with two lines of context rather than centred, since centring is
measured against the list's own box, which inside a sheet is taller than the
part the reader can see.

Verified on an Android device at the default code size and at 20px: preview
and Open both show the highlighted text, across files and between two
occurrences in one file.
2026-09-10 13:01:47 +02:00
Mohamed Boudra 6e57bb1e4f fix(app): tell the truth about search scope and file identity
Two reproduced failures from the full correctness review.

An empty result claimed the whole workspace had been read. Ripgrep never
opens a file above the host's per-file ceiling, so a workspace whose only
match lives in a large log answered "No matches" with nothing else said, and
adding a small match still reported a complete result. The response now
carries the ceiling the host actually applied, and the empty state names it.
Only the empty state, because that is the only place the UI claims
completeness; a populated list already claims nothing, and the occurrence-cap
notice stays what it was. No permanent scope strip returns.

A file literally named "a\b.txt" previewed itself and then opened "a/b.txt".
The client rewrote every backslash to a separator on paths the host had
already made canonical, so a valid Unix file name became a directory and a
different file opened under a false "File changed" notice. Identity is now
kept as the producer gave it: the daemon renders workspace-relative paths with
its own separator turned into "/" for content search the way the file explorer
already did, and the client only normalises separators for a path whose own
shape is Windows, never from the client's operating system.

Native source previews sized every row at a hard-coded 20px while the row is
really one line at the reader's configurable code size, so scrolling to a
match landed in the wrong part of the file. The height is derived from the
size the view is already handed. Revealing a match thousands of lines into a
long file remains broken on Android beyond this; that is recorded with device
evidence rather than claimed fixed.
2026-09-10 11:45:15 +02:00
Mohamed Boudra 6db8bd124f fix(app): put content search in the real Command Center
The content scope replaced the whole panel with a lookalike shell: its own
query field pinned to the bottom, its own scope button, and a permanent strip
naming the implementation ("Saved files up to 1 MiB · Ignore case"). Search
now uses the Command Center's own header, so every scope shares one input,
one chip, one loading slot and one key dispatcher. Content search contributes
only a body: results left, source preview right, in a wider panel.

The strip is gone. Idle shows nothing, an empty result set reuses the shared
"No matches", and loading and errors are the existing spinner and Retry.

Two row defects fell out of the wider layout. A deep path truncated its own
file name away, so a row could not be identified; the name and coordinates now
lead and the directory is what truncation eats. A match far into a long line
sat outside the row entirely; the run-up is trimmed when there is enough of it
to be worth trimming. The preview centres such a match horizontally instead of
leaving it clipped at the scroller edge.

Pane Find required pane context, which an overlay preview does not have, so
rendering a source view inside the Command Center threw. Find now belongs to
panes only and stands down elsewhere, and the file pane routes through the
source module instead of rebuilding its theme.
2026-09-10 10:13:15 +02:00
Mohamed Boudra eb1fe10375 feat(app): search saved workspace contents from Command Center 2026-09-10 09:38:19 +02:00
Mohamed Boudra db8c22e135 fix(app): stop the Find placeholder from hiding Go to line
Hiding `.cm-panels` suppressed every CodeMirror panel in the file editor,
including the Go to line dialog that Mod-Alt-g opens through the same
search keymap. Hide only the inert Find placeholder panel instead, by its
own class, and leave the shared panel container alone.
2026-09-09 20:51:02 +02:00
Mohamed Boudra 0c38afa76f fix(app): keep the active match visible and drop the Find button
The floating widget covered the active match whenever the match sat under
it and the document was too short to scroll clear. CodeMirror's own panel
scroll margin cannot reveal a first line, so the file integration now owns
placement: the widget renders as an overlay beside the editor, the model
measures it against the active match, and flips it to the opposite corner
only when the current corner collides. Hysteresis keeps navigation from
bouncing the widget between corners.

Also removes the standalone Find button shown while Find is closed, per
user direction. Cmd/Ctrl+F is the entry point.
2026-09-09 20:38:48 +02:00
Mohamed Boudra 7b813f7f58 fix(app): float the pane Find widget and tighten its chrome
Find pushed the first lines of the file down, ran 480px wide with 32px
ghost buttons, and let the replacement input start left of the query
input. Float the CodeMirror top panel out of flow so the widget overlays
the top right of the pane, move the disclosure toggle into a gutter that
spans both rows so the two fields share a leading rail, put the match
count inside the query field, and drop the icon controls to the shared
pane-toolbar chrome the file bar above already uses.
2026-09-09 20:09:55 +02:00
Mohamed Boudra d014cfdb2f feat(app): add pane Find to file source 2026-09-09 19:40:57 +02:00
72 changed files with 4631 additions and 267 deletions
+8
View File
@@ -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
+2
View File
@@ -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
@@ -102,8 +102,8 @@ test("dropping the files scope leaves the search row the same height", async ({
const header = panel.getByTestId("command-center-header");
const scoped = await header.boundingBox();
await page.getByTestId("command-center-files-scope").click();
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
await page.getByTestId("command-center-scope").click();
await expect(page.getByTestId("command-center-scope")).toHaveCount(0);
const unscoped = await header.boundingBox();
expect(scoped?.height).toBe(unscoped?.height);
+430
View File
@@ -0,0 +1,430 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
function source(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
}
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" });
}
async function openSource(page: Page, filename: string) {
await openFileExplorer(page);
await openFileFromExplorer(page, filename);
await expect(source(page)).toBeVisible();
}
async function findInSource(page: Page, text: string) {
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill(text);
}
async function expectQuerySelected(page: Page, text: string) {
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue(text);
await expect
.poll(() =>
query(page).evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd]),
)
.toEqual([0, text.length]);
}
async function closeFind(page: Page) {
await query(page).press("Escape");
await expect(source(page)).toBeFocused();
}
async function expectActiveMatchUncovered(page: Page) {
const match = page.locator(".cm-searchMatch-selected").filter({ visible: true }).first();
await expect(match).toBeVisible();
const widget = page.getByLabel("Find", { exact: true }).filter({ visible: true });
await expect
.poll(async () => {
const m = await match.boundingBox();
const w = await widget.boundingBox();
if (!m || !w) return "missing";
const covered =
m.x < w.x + w.width && m.x + m.width > w.x && m.y < w.y + w.height && m.y + m.height > w.y;
return covered ? "covered by Find" : "visible";
})
.toBe("visible");
}
async function findBoxes(page: Page) {
const pane = (await page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.boundingBox())!;
const widget = (await page
.getByLabel("Find", { exact: true })
.filter({ visible: true })
.boundingBox())!;
return { pane, widget };
}
async function expectFindInsidePane(page: Page) {
const { pane, widget } = await findBoxes(page);
expect(widget.x).toBeGreaterThanOrEqual(pane.x);
expect(widget.x + widget.width).toBeLessThanOrEqual(pane.x + pane.width);
expect(widget.y).toBeGreaterThanOrEqual(pane.y);
expect(widget.y + widget.height).toBeLessThanOrEqual(pane.y + pane.height + 1);
}
async function expectFindAtTopRight(page: Page) {
await expectFindInsidePane(page);
const { pane, widget } = await findBoxes(page);
expect(widget.y).toBeLessThan(pane.y + 70);
}
test("finds literal text at the top of editable source and returns focus at the inspected match", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-" });
const file = path.join(workspace.repoPath, "source.txt");
await writeFile(file, "first a.b\nsecond axb\nthird a.b\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await findInSource(page, "a.b");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("A.B");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("a.b");
await expectFindAtTopRight(page);
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
await query(page).press("Shift+Enter");
await expect(status(page)).toHaveText("1 of 2");
await page.screenshot({ path: testInfo.outputPath("editable-find.png") });
await closeFind(page);
await expect(query(page)).toBeHidden();
await expect(page.getByLabel("Line 1, column 10")).toBeVisible();
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
await test.step("reopen with the shortcut, preserve replace and Undo/save", async () => {
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("literal");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(source(page)).toContainText("first literal");
await expect(status(page)).toHaveText("1 of 1");
await page.screenshot({ path: testInfo.outputPath("replace.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+z");
await expect(source(page)).toContainText("first a.b");
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("first a.b\nsecond axb\nthird a.b\n");
await findInSource(page, "a.b");
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("all");
await page.getByRole("button", { name: "Replace all", exact: true }).click();
await expect(status(page)).toHaveText("No matches");
await closeFind(page);
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("first all\nsecond axb\nthird all\n");
});
});
test("searches the unsaved buffer while disconnected", async ({ page, withWorkspace }) => {
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace({ prefix: "pane-find-draft-" });
const file = path.join(workspace.repoPath, "draft.txt");
await writeFile(file, "saved on disk\n");
await workspace.navigateTo();
await openSource(page, "draft.txt");
await gate.drop();
await source(page).fill("unsaved needle\nsecond needle\n");
await findInSource(page, "needle");
await expect(status(page)).toHaveText("1 of 2");
expect(await readFile(file, "utf8")).toBe("saved on disk\n");
gate.restore();
});
test("searches read-only source beyond the viewport without replace controls", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-readonly-" });
await writeFile(
path.join(workspace.repoPath, "large.txt"),
"needle first\n" + "plain source line\n".repeat(70_000) + "needle last\n",
);
await workspace.navigateTo();
await openSource(page, "large.txt");
await findInSource(page, "needle");
await expect(source(page)).toHaveAttribute("contenteditable", "false");
await expect(status(page)).toHaveText("1 of 2");
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
await expect(source(page)).toContainText("needle last");
await page.screenshot({ path: testInfo.outputPath("readonly-find.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("plain");
await expect(status(page)).toHaveText("1 of 10000+");
});
test("targets the focused source in split panes and refocuses an open query", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-split-" });
await writeFile(path.join(workspace.repoPath, "left.txt"), "left needle\n");
await writeFile(path.join(workspace.repoPath, "right.txt"), "right needle\nright needle\n");
await workspace.navigateTo();
await openSource(page, "left.txt");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await openFileFromExplorer(page, "right.txt");
const left = source(page).filter({ hasText: "left needle" });
const right = source(page).filter({ hasText: "right needle" });
await expect(left).toBeVisible();
await expect(right).toBeVisible();
await left.click();
await left.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 1");
await query(page).press("Escape");
await right.click();
await right.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await right.click();
await right.press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue("needle");
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
});
test.describe("narrow touch browser", () => {
test.use({ hasTouch: true });
test("keeps Find controls inside a narrow source pane", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-touch-" });
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
await workspace.navigateTo();
await openSource(page, "touch.txt");
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("touch-find-closed.png") });
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectFindInsidePane(page);
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
});
async function selectFirstTwoLines(page: Page) {
await source(page).focus();
await source(page).press("ControlOrMeta+Home");
await source(page).press("Shift+ArrowDown");
await source(page).press("Shift+End");
}
async function selectLastWord(page: Page) {
await source(page).focus();
await source(page).press("ControlOrMeta+End");
await source(page).press("ArrowUp");
await source(page).press("Home");
await source(page).press("Shift+End");
}
test("declines multiline selection seeds and keeps replacement aligned with the visible query", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-single-line-" });
const file = path.join(workspace.repoPath, "selection.txt");
await writeFile(file, "alpha\nbeta\nalphabeta\n");
await workspace.navigateTo();
await openSource(page, "selection.txt");
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
await query(page).press("Enter");
await expect(query(page)).toHaveValue("");
await closeFind(page);
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+g");
await expect(query(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
await closeFind(page);
await selectLastWord(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toHaveValue("alphabeta");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await closeFind(page);
await expect(page.getByLabel("Line 3, column 10")).toBeVisible();
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toHaveValue("alphabeta");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("replaced");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(source(page)).toContainText("alpha");
await expect(source(page)).toContainText("beta");
await expect(status(page)).toHaveText("No matches");
await page.screenshot({ path: testInfo.outputPath("single-line-query-replacement.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("alpha\nbeta\nreplaced\n");
});
for (const startingField of ["Find in pane", "Replace with"] as const) {
test(`repeated Find from ${startingField} selects the query for new typing`, async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-repeat-input-" });
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await findInSource(page, "needle");
await page.getByRole("button", { name: "Toggle replace" }).click();
const replacement = page.getByRole("textbox", { name: "Replace with" });
await replacement.fill("replacement");
await test.step("repeat Find from the input and type a fresh query", async () => {
const startingInput = page.getByRole("textbox", { name: startingField, exact: true });
await startingInput.press("End");
await startingInput.press("ControlOrMeta+f");
await expectQuerySelected(page, "needle");
await page.keyboard.type("first");
await expect(query(page)).toHaveValue("first");
await expect(replacement).toHaveValue("replacement");
await expect(status(page)).toHaveText("1 of 1");
});
await page.screenshot({ path: testInfo.outputPath("repeat-find-input.png") });
await closeFind(page);
await expect(page.getByLabel("Line 1, column 13")).toBeVisible();
});
}
test.describe("the active match stays visible under the floating widget", () => {
test.describe("narrow touch browser", () => {
test.use({ hasTouch: true });
test("reveals a short file's match that the widget would cover", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
await workspace.navigateTo();
await openSource(page, "touch.txt");
await page.setViewportSize({ width: 390, height: 844 });
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await expectActiveMatchUncovered(page);
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
await expect(query(page)).toHaveValue("needle");
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
});
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-cover-split-" });
await writeFile(path.join(workspace.repoPath, "left.txt"), "left filler\n");
await writeFile(path.join(workspace.repoPath, "right.txt"), "needle one\nplain\n");
await workspace.navigateTo();
await openSource(page, "left.txt");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await openFileFromExplorer(page, "right.txt");
const right = source(page).filter({ hasText: "needle one" });
await expect(right).toBeVisible();
await right.click();
await right.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 1");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("split-match-uncovered.png") });
await test.step("stays visible when Replace expands the widget", async () => {
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("hay");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("split-replace-uncovered.png") });
});
await test.step("navigation and query focus still work", async () => {
await query(page).press("ControlOrMeta+f");
await expectQuerySelected(page, "needle");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await expectActiveMatchUncovered(page);
});
await query(page).press("Escape");
await expect(right).toBeFocused();
});
});
test("Go to line keeps its dialog with Find closed and open", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-goto-" });
const lines = Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join("\n");
await writeFile(path.join(workspace.repoPath, "lines.txt"), `${lines}\n`);
await workspace.navigateTo();
await openSource(page, "lines.txt");
const gotoInput = page.getByRole("textbox", { name: "Go to line" });
await test.step("with Find closed", async () => {
await source(page).focus();
await source(page).press("ControlOrMeta+Alt+g");
await expect(gotoInput).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("goto-line-find-closed.png") });
await gotoInput.fill("12");
await gotoInput.press("Enter");
await expect(page.getByLabel("Line 12, column 1")).toBeVisible();
await expect(gotoInput).toBeHidden();
});
await test.step("with Find open", async () => {
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("line 3");
await source(page).focus();
await source(page).press("ControlOrMeta+Alt+g");
await expect(gotoInput).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("goto-line-find-open.png") });
await gotoInput.fill("30");
await gotoInput.press("Enter");
await expect(page.getByLabel("Line 30, column 1")).toBeVisible();
await expect(query(page)).toHaveValue("line 3");
await expect(status(page)).toHaveText("11 matches");
});
});
@@ -0,0 +1,63 @@
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";
// The missing-rg fixture builds a PATH from `which` and POSIX symlinks. Windows needs its own
// PATH construction, which belongs to the CI gate rather than to this journey.
base.skip(process.platform === "win32", "the missing-ripgrep PATH fixture is POSIX-shaped");
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();
});
@@ -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);
}
@@ -0,0 +1,195 @@
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: '<svg xmlns="http://www.w3.org/2000/svg"><title>needle</title></svg>',
result: 'icon.svg:1:48 <svg xmlns="http://www.w3.org/2000/svg"><title>needle</title></svg>',
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"),
);
});
import {
openLiteralBackslashFile,
reportSkippedLargeFiles,
} from "../support/helpers/workspace-content-search";
test("an empty result says which files the host never opened", async ({ page, withWorkspace }) => {
await reportSkippedLargeFiles(page, await withWorkspace());
});
// "a\b.txt" is one file on POSIX and a nested path on Windows, so only this fixture is unavailable
// there. The skip belongs to this test: at file scope it annotates the whole suite and takes every
// other journey in this file with it. Windows separator handling is covered by
// resolveWorkspaceFilePaths and toWorkspaceRelativePath.
test("a file name containing a backslash opens itself, not a same-named nested path", async ({
page,
withWorkspace,
}) => {
test.skip(
process.platform === "win32",
"a file name containing a backslash is not a valid Windows fixture",
);
await openLiteralBackslashFile(page, await withWorkspace());
});
import { reopenSameOccurrenceAfterMovingAway } from "../support/helpers/workspace-content-search";
test("opening the same occurrence again navigates the pane it is already showing", async ({
page,
withWorkspace,
}) => {
await reopenSameOccurrenceAfterMovingAway(page, await withWorkspace());
});
import { revealExactPathOnHover } from "../support/helpers/workspace-content-search";
test("a result's tooltip paints the exact path, however deep", async ({
page,
withWorkspace,
}, testInfo) => {
const { rows, tip } = await revealExactPathOnHover(page, await withWorkspace());
await rows.first().hover();
await expect(tip).toBeVisible();
await page.waitForTimeout(400);
await page.screenshot({ path: testInfo.outputPath("tooltip-exact-path.png") });
// Pointing must not move the keyboard selection: Enter still opens the first result.
await page.keyboard.press("Enter");
await expect(page.getByTestId("command-center-panel")).toBeHidden();
await expect(page.getByTestId("file-source-editor")).toContainText("SHARED_UTIL");
});
import { keepDesktopOverlayChrome } from "../support/helpers/workspace-content-search";
test("the desktop panel keeps its backdrop, Escape and focus return in every scope", async ({
page,
withWorkspace,
}) => {
await keepDesktopOverlayChrome(page, await withWorkspace());
});
@@ -26,7 +26,7 @@ const SURFACE_SELECTORS = {
input: '[data-testid="command-center-input"]',
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
results: '[data-testid="command-center-results"]',
scope: '[data-testid="command-center-files-scope"]',
scope: '[data-testid="command-center-scope"]',
fileRow: '[data-testid^="command-center-file-row-"]',
fileIcon: '[data-testid="command-center-file-icon"]',
fileLine: '[data-testid="command-center-file-line"]',
@@ -104,19 +104,20 @@ function stripAssistantMessageId(
return JSON.stringify(envelope);
}
function stripCanonicalSubmittedPrompts(
function stripServerFeatures(
message: string | Buffer,
enabled: boolean,
omittedFeatures: Set<string>,
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<string, unknown> } };
payload?: { status?: unknown; features?: Record<string, unknown> };
};
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<string>();
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;
@@ -0,0 +1,347 @@
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { composerLocator } from "./composer";
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();
// The query field is the Command Center's own header input, above the results and preview.
const inputBox = await input.boundingBox();
const previewBox = await panel.getByTestId("file-source-editor").boundingBox();
expect(inputBox!.y + inputBox!.height).toBeLessThan(previewBox!.y);
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 });
}
export async function reportSkippedLargeFiles(page: Page, workspace: CreatedWorkspace) {
// rg never opens a file above the host ceiling, so an unqualified "No matches" would claim the
// whole workspace was read.
await writeFile(path.join(workspace.repoPath, "large.txt"), `needle${" ".repeat(1024 * 1024)}`);
await workspace.navigateTo();
await page.keyboard.press("Meta+Shift+F");
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
await input.fill("needle");
const panel = page.getByTestId("command-center-panel");
await expect(panel.getByText("No matches", { exact: true })).toBeVisible();
await expect(
panel.getByText("Files over 1.0 MB were not searched", { exact: true }),
).toBeVisible();
// The occurrence-cap notice is a different claim and must not appear here.
await expect(panel.getByText(/Results limited/)).toHaveCount(0);
}
export async function openLiteralBackslashFile(page: Page, workspace: CreatedWorkspace) {
// A backslash is an ordinary character in a Unix file name; "a\b.txt" is not "a/b.txt".
await writeFile(path.join(workspace.repoPath, "a\\b.txt"), "needle LITERAL FILE");
await mkdir(path.join(workspace.repoPath, "a"), { recursive: true });
await writeFile(path.join(workspace.repoPath, "a", "b.txt"), "THIS IS THE OTHER FILE");
await workspace.navigateTo();
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: /a\\b\.txt:1:1/ })).toBeVisible();
await expect(panel.getByTestId("file-source-editor")).toContainText("LITERAL FILE");
await page.keyboard.press("Enter");
await expect(panel).toBeHidden();
const source = page.getByTestId("file-source-editor");
await expect(source).toContainText("LITERAL FILE");
await expect(source).not.toContainText("THIS IS THE OTHER FILE");
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
}
/**
* Opening the same occurrence twice has to navigate the pane both times. The tab target is
* deliberately identity-stable, so the second activation looks like a no-op unless it asks the
* file-opening owner to navigate again.
*/
export async function reopenSameOccurrenceAfterMovingAway(page: Page, workspace: CreatedWorkspace) {
const lines = Array.from(
{ length: 1200 },
(_value, index) => `export const filler${index} = ${index};`,
);
lines.push('const marker = "REOPEN_TARGET";');
await writeFile(path.join(workspace.repoPath, "reopen.ts"), `${lines.join("\n")}\n`);
await workspace.navigateTo();
const scroller = page.getByTestId("file-source-editor").locator(".cm-scroller");
const offset = () => scroller.evaluate((element) => element.scrollTop);
await openReopenTarget(page);
const revealed = await offset();
expect(revealed).toBeGreaterThan(0);
await expect(
page.getByTestId("file-source-editor").locator(".cm-selectionBackground"),
).toBeInViewport();
// Move the caret to the top of the same file, inside the same editor instance.
await page.getByTestId("file-source-editor").locator(".cm-content").click();
await page.keyboard.press("ControlOrMeta+Home");
await expect.poll(offset).toBeLessThan(revealed / 2);
await openReopenTarget(page);
await expect
.poll(offset, { message: "reopening the same occurrence must navigate the pane again" })
.toBeGreaterThan(revealed / 2);
await expect(
page.getByTestId("file-source-editor").locator(".cm-selectionBackground"),
).toBeInViewport();
// The editor instance is reused, so its history survives the second navigation.
await expect(page.getByTestId("file-source-editor")).toContainText("REOPEN_TARGET");
}
async function openReopenTarget(page: Page) {
await page.keyboard.press("Meta+Shift+F");
const panel = page.getByTestId("command-center-panel");
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill("REOPEN_TARGET");
await expect(panel.getByRole("button", { name: /reopen\.ts:1201:/ })).toBeVisible();
await page.keyboard.press("Enter");
await expect(panel).toBeHidden();
}
/**
* Two files can share a base name and their nearest parent, and a real path is often wider than
* the result column, so the exact workspace-relative path lives in the tooltip. Being in the DOM
* is not enough: the tooltip has to be the thing painted at its own coordinates.
*/
export async function revealExactPathOnHover(page: Page, workspace: CreatedWorkspace) {
const deep =
"packages/app/src/command-center/workspace-content-search/internal/deeply/nested/result-path-presentation.ts";
for (const relative of [
"packages/app/src/utils/index.ts",
"packages/server/src/utils/index.ts",
deep,
]) {
const target = path.join(workspace.repoPath, relative);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, `export const shared = "SHARED_UTIL";\n`);
}
await workspace.navigateTo();
await page.keyboard.press("Meta+Shift+F");
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill("SHARED_UTIL");
const panel = page.getByTestId("command-center-panel");
const rows = panel.getByRole("button", { name: /:1:24/ });
await expect(rows).toHaveCount(3);
const tip = page.getByTestId("content-search-row-path");
async function expectPaintedPath(row: Locator, expected: string) {
await row.hover();
await expect(tip).toHaveText(expected);
// The reader must actually see it: whatever is painted at the tooltip's own coordinates has
// to be the tooltip, not the panel that used to cover it.
// A tooltip is pointer-transparent, so elementFromPoint reports the row underneath either
// way, and its surface is the same white as the panel. What settles it is whether the pixels
// in its own rectangle change when it opens: if the panel still covers it, they do not.
const box = await tip.boundingBox();
if (!box) throw new Error("tooltip has no box");
const clip = { x: box.x, y: box.y, width: box.width, height: box.height };
const shown = await page.screenshot({ clip });
await page.mouse.move(2, 2);
await expect(tip).toBeHidden();
const hidden = await page.screenshot({ clip });
expect(
shown.equals(hidden),
"the tooltip's own rectangle must change when it opens, or the reader cannot see it",
).toBe(false);
}
// Same base name and same nearest parent: the resting labels cannot tell these apart.
const app = rows.filter({ hasText: "…/utils/index.ts:1:24" }).first();
const server = rows.filter({ hasText: "…/utils/index.ts:1:24" }).last();
await expect(app).toHaveAccessibleName(/^packages\/app\/src\/utils\/index\.ts:1:24 /);
await expect(server).toHaveAccessibleName(/^packages\/server\/src\/utils\/index\.ts:1:24 /);
await expectPaintedPath(app, "packages/app/src/utils/index.ts:1:24");
await expectPaintedPath(server, "packages/server/src/utils/index.ts:1:24");
// A path far wider than the result column still reads in full.
await expectPaintedPath(
rows.filter({ hasText: "result-path-presentation.ts" }).first(),
`${deep}:1:24`,
);
return { panel, rows, tip };
}
/**
* The overlay chrome the desktop panel owns: a backdrop that dismisses it, Escape that dismisses
* it, and focus that goes back where it came from. Hosting the panel in the shared overlay root
* rather than a native Modal must not change any of it.
*/
export async function keepDesktopOverlayChrome(page: Page, workspace: CreatedWorkspace) {
await writeFile(path.join(workspace.repoPath, "chrome.ts"), 'const a = "CHROME_TARGET";\n');
await workspace.navigateTo();
const panel = page.getByTestId("command-center-panel");
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
// Backdrop dismisses the panel.
const composer = composerLocator(page);
await composer.click();
await expect(composer).toBeFocused();
await page.keyboard.press("Meta+Shift+F");
await expect(input).toBeFocused();
await page.mouse.click(page.viewportSize()!.width - 12, page.viewportSize()!.height - 12);
await expect(panel).toBeHidden();
// Escape dismisses it and hands focus back to the composer it was opened from.
await page.keyboard.press("Meta+Shift+F");
await input.fill("CHROME_TARGET");
await expect(panel.getByRole("button", { name: /chrome\.ts:1:/ })).toBeVisible();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
await expect(composer).toBeFocused();
// Ordinary commands and the Files scope still open over the same chrome.
await page.keyboard.press("Meta+k");
await expect(page.getByTestId("command-center-input")).toBeFocused();
await page.keyboard.press("Escape");
await page.keyboard.press("Meta+p");
await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
}
@@ -1,3 +1,4 @@
import { WorkspaceContentSearch } from "./workspace-content-search";
import {
FlatList,
Modal,
@@ -10,7 +11,8 @@ import {
type NativeSyntheticEvent,
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -36,6 +38,7 @@ import { isNative, isWeb } from "@/constants/platform";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
getOverlayRoot,
OverlayLayerProvider,
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
@@ -90,7 +93,18 @@ const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMut
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const SCOPE_LABELS = {
files: {
chip: "shell.commandCenter.files",
placeholder: "shell.commandCenter.filePlaceholder",
},
content: {
chip: "shell.commandCenter.content",
placeholder: "shell.commandCenter.contentPlaceholder",
},
} as const;
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const CONTENT_SEARCH_SNAP_POINTS = ["90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
@@ -259,7 +273,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<CommandCenterResultSection[]>(() => {
@@ -584,6 +598,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<FlatList<CommandCenterListRow>>(null);
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
@@ -700,18 +715,30 @@ export function CommandCenter() {
onScroll: handleListScroll,
scrollEventThrottle: 16,
};
const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
// Every scope wears the same header; only the chip and the placeholder name it.
const scopeLabels = state.scope ? SCOPE_LABELS[state.scope] : null;
const scopeChipLabel = scopeLabels ? t(scopeLabels.chip) : null;
const placeholder = t(scopeLabels?.placeholder ?? "shell.commandCenter.placeholder");
// Content search owns arrows, Enter and Backspace while its scope is active; every other scope
// is driven by the shared result list. One dispatcher so the input, submit and the web overlay
// can never disagree about who is steering.
const dispatchKey = useCallback(
(key: string) =>
state.scope === "content" ? (contentKeyHandler.current?.(key) ?? false) : state.key(key),
[state],
);
const submit = useCallback(() => state.key("Enter"), [state]);
const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => dispatchKey(key),
[dispatchKey],
);
const submit = useCallback(() => dispatchKey("Enter"), [dispatchKey]);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!state.key(event.key)) return false;
if (!dispatchKey(event.key)) return false;
event.preventDefault();
return true;
},
[state],
[dispatchKey],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && state.open && !showBottomSheet,
@@ -730,7 +757,9 @@ export function CommandCenter() {
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={null}
snapPoints={COMMAND_CENTER_SNAP_POINTS}
snapPoints={
state.scope === "content" ? CONTENT_SEARCH_SNAP_POINTS : COMMAND_CENTER_SNAP_POINTS
}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
@@ -744,9 +773,7 @@ export function CommandCenter() {
accessible={false}
>
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
{scopeChipLabel ? <ScopeChip label={scopeChipLabel} onRemove={state.clearScope} /> : null}
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
@@ -755,11 +782,7 @@ export function CommandCenter() {
onChangeText={state.setQuery}
onKeyPress={keyPress}
onSubmitEditing={submit}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
placeholder={placeholder}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
@@ -770,49 +793,90 @@ export function CommandCenter() {
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
{state.scope === "content" ? (
<WorkspaceContentSearch
query={state.query}
compact={isCompact}
close={state.close}
clearScope={state.clearScope}
keyHandler={contentKeyHandler}
/>
) : (
<>
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</>
)}
</IsolatedBottomSheetModal>
);
}
if (!state.open) return null;
return (
const desktopContent = (
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
initialValue={state.query}
onChangeText={state.setQuery}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{fileSearchError}
<FlatList ref={listRef} {...commonListProps} />
<View style={[styles.overlay, isWeb && { zIndex: modalLayer }]}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View
ref={setWebOverlayScope}
testID="command-center-panel"
style={[styles.panel, state.scope === "content" && styles.contentPanel]}
>
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
{scopeChipLabel ? (
<ScopeChip label={scopeChipLabel} onRemove={state.clearScope} />
) : null}
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
initialValue={state.query}
onChangeText={state.setQuery}
placeholder={placeholder}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{state.scope === "content" ? (
<WorkspaceContentSearch
query={state.query}
compact={isCompact}
close={state.close}
clearScope={state.clearScope}
keyHandler={contentKeyHandler}
/>
) : (
<>
{fileSearchError}
<FlatList ref={listRef} {...commonListProps} />
</>
)}
</View>
</Modal>
</View>
</OverlayLayerProvider>
);
return hostDesktopOverlay(desktopContent, state.close);
}
/**
* Desktop web hosts the panel in the shared overlay root rather than React Native Web's `Modal`,
* which paints in a plane of its own above it. Inside that plane an ordinary portal — the tooltip
* on a result row — can never cover the panel however high its own z-index, so the row could not
* show a reader its exact path. `adaptive-modal-sheet` already hosts desktop modals this way; see
* docs/floating-panels.md. Native keeps the Modal.
*/
function hostDesktopOverlay(content: ReactElement, onRequestClose: () => void): ReactElement {
if (isWeb && typeof document !== "undefined") {
return createPortal(content, getOverlayRoot());
}
return (
<Modal visible transparent animationType="fade" onRequestClose={onRequestClose}>
{content}
</Modal>
);
}
function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; label: string }) {
@@ -841,7 +905,7 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
accessibilityLabel={label}
onPress={onRemove}
style={styles.scopeChip}
testID="command-center-files-scope"
testID="command-center-scope"
>
<ThemedFolder size={14} strokeWidth={2.2} />
<Text style={styles.scopeChipLabel}>{label}</Text>
@@ -852,12 +916,14 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
...StyleSheet.absoluteFillObject,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: theme.spacing[12],
pointerEvents: "auto",
},
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
contentPanel: { width: 1000, maxWidth: "96%", height: "75%" },
panel: {
width: 640,
height: 560,
@@ -0,0 +1,513 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { FlatList, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import type { WorkspaceContentMatch } from "@getpaseo/protocol/messages";
import { isWeb } from "@/constants/platform";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
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 { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { WorkspaceFileSource } from "@/file-pane/source";
import { formatFileSize } from "@/utils/format-file-size";
import { describeResultPath } from "./internal/result-path";
import {
WorkspaceContentSearchModel,
type ContentSearchSnapshot,
type ContentSearchTransport,
} from "./internal/model";
const ThemedSpinner = withUnistyles(LoadingSpinner, (theme) => ({
color: theme.colors.foregroundMuted,
}));
interface Props {
/** The Command Center owns the field; this body only reacts to what was typed in it. */
query: string;
compact: 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 <Text style={styles.status}>{t("shell.commandCenter.contentUnavailable")}</Text>;
// COMPAT(workspaceContentSearch): added in v0.8.0, remove after 2027-03-09 once daemon floor >= v0.8.0.
if (!session.serverInfo?.features?.workspaceContentSearch)
return <Text style={styles.status}>{t("shell.commandCenter.contentUpdateHost")}</Text>;
return (
<ContentSearch
key={`${serverId}:${workspaceId}`}
{...props}
transport={session.client}
cwd={cwd}
serverId={serverId}
workspaceId={workspaceId}
/>
);
}
function ContentSearch({
transport,
cwd,
serverId,
workspaceId,
query,
compact,
close,
clearScope,
keyHandler,
}: Props & {
transport: ContentSearchTransport;
cwd: string;
serverId: string;
workspaceId: string;
}) {
const [previewPage, setPreviewPage] = useState(false);
const closeRef = useRef(close);
closeRef.current = close;
const model = useMemo(
() =>
new WorkspaceContentSearchModel({
cwd,
transport,
open(location) {
clearCommandCenterFocusRestoreElement();
const layout = useWorkspaceLayoutStore.getState();
openWorkspaceFileInFocusedPane({
location,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
closeExplorerAfterOpen: true,
showMobileAgent: usePanelStore.getState().showMobileAgent,
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
layout.openTab({ workspaceKey, target, intent: "reveal", placement }),
focusWorkspaceTab: layout.focusTab,
requestFileNavigation: layout.requestFileNavigation,
});
closeRef.current();
},
}),
[cwd, transport, serverId, workspaceId],
);
useEffect(() => () => model.dispose(), [model]);
// The field lives in the Command Center header, so the typed value is a prop; this is the only
// bridge from it into the model that owns the search.
useEffect(() => {
setPreviewPage(false);
model.setQuery(query);
}, [model, query]);
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const listRef = useRef<FlatList>(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 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 }) => (
<ResultRow item={item} index={index} selected={state.activeIndex === index} select={select} />
),
[select, state.activeIndex],
);
const emptyState = useMemo(
() => <SearchState state={state} retry={model.retry} />,
[model.retry, state],
);
const limitNotice = useMemo(() => <ResultLimit limited={state.limited} />, [state.limited]);
return (
<View style={styles.body}>
{!compact || !previewPage ? (
<View style={[styles.results, compact && styles.full]}>
<FlatList
ref={listRef}
data={state.matches}
keyboardShouldPersistTaps="handled"
keyExtractor={resultKey}
getItemLayout={resultLayout}
renderItem={renderItem}
ListEmptyComponent={emptyState}
ListFooterComponent={limitNotice}
/>
</View>
) : null}
{!compact || previewPage ? (
<ContentPreview state={state} model={model} compact={compact} back={back} />
) : null}
</View>
);
}
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]);
// Same hover/selection idiom as the Command Center's own result rows, one tint apart so the
// keyboard cursor that drives the preview stays distinguishable from the pointer.
const style = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(Boolean(hovered) || pressed) && styles.hovered,
selected && styles.selected,
],
[selected],
);
const line = useMemo(() => describeMatchLine(item), [item]);
const file = useMemo(() => describeResultPath(item.path), [item.path]);
const location = `${item.path}:${item.line}:${item.columnStart}`;
return (
// The resting row shows the file and its nearest parent, which two files can share
// (packages/app/src/utils/index.ts and packages/server/src/utils/index.ts). The tooltip is
// where the exact workspace-relative path lives; it is not bounded by the column's width.
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${location} ${item.snippet}`}
accessibilityState={accessibilityState}
aria-pressed={isWeb ? selected : undefined}
onPress={onPress}
style={style}
>
<Text style={styles.snippet} numberOfLines={1}>
{line.before}
<Text style={styles.match}>{line.match}</Text>
{line.after}
</Text>
{/* The name and coordinates are pinned; only the parent context may shrink, so a deep
path loses its middle rather than the identity the reader is scanning for. */}
<View style={styles.location}>
{file.directory ? (
<Text style={styles.locationDirectory} numberOfLines={1}>
{file.directory}
</Text>
) : null}
<Text style={styles.locationFile} numberOfLines={1}>
{file.name}:{item.line}:{item.columnStart}
</Text>
</View>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" maxWidth={520} testID="content-search-row-path">
<Text style={styles.tooltipPath}>{location}</Text>
</TooltipContent>
</Tooltip>
);
}
/** The row is one line wide, so the match has to survive indentation and long-line offsets. */
function describeMatchLine(item: WorkspaceContentMatch) {
// Only a run-up long enough to be worth cutting is cut, so an ordinary line keeps its start.
const cut = item.snippetMatchStart > SNIPPET_LEAD * 2;
const from = cut ? item.snippetMatchStart - SNIPPET_LEAD : 0;
const before = item.snippet.slice(from, item.snippetMatchStart);
return {
before: cut ? `…${before}` : before.trimStart(),
match: item.snippet.slice(item.snippetMatchStart, item.snippetMatchEnd),
after: item.snippet.slice(item.snippetMatchEnd),
};
}
function resultKey(item: WorkspaceContentMatch) {
return `${item.path}:${item.line}:${item.columnStart}`;
}
function resultLayout(_data: ArrayLike<WorkspaceContentMatch> | null | undefined, index: number) {
return { length: RESULT_ROW_HEIGHT, offset: index * RESULT_ROW_HEIGHT, index };
}
/** Idle stays blank: the header placeholder already says what the field does. */
function SearchState({ state, retry }: { state: ContentSearchSnapshot; retry(): void }) {
const { t } = useTranslation();
if (state.status === "searching")
return (
<View style={styles.status} accessibilityLiveRegion="polite">
<ThemedSpinner size={14} />
<Text style={styles.muted}>{t("shell.commandCenter.searchingFiles")}</Text>
</View>
);
if (state.status === "error")
return (
<View style={styles.status} accessibilityLiveRegion="polite">
<Text style={[styles.muted, styles.error]}>{state.message}</Text>
<Button variant="outline" size="sm" onPress={retry}>
{t("common.actions.retry")}
</Button>
</View>
);
if (state.status === "ready")
return (
<View style={styles.empty}>
<Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>
{/* "No matches" alone claims the whole workspace was read. Files above the host's ceiling
are never opened, so the empty state has to say so where the claim is made. */}
{state.maxFileBytes ? (
<Text style={styles.emptyDetail}>
{t("shell.commandCenter.contentSkippedLargeFiles", {
size: formatFileSize(state.maxFileBytes),
})}
</Text>
) : null}
</View>
);
return null;
}
function ResultLimit({ limited }: { limited: boolean }) {
const { t } = useTranslation();
if (!limited) return null;
return <Text style={styles.limit}>{t("shell.commandCenter.contentLimited")}</Text>;
}
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 (
<View style={styles.preview}>
{/* On desktop the selected row already names the file and Enter opens it, so the preview
carries no header. The compact flow pages away from the list and still needs both. */}
{compact && match ? (
<View style={styles.previewHeader}>
<Button variant="ghost" size="sm" onPress={back}>
{t("shell.commandCenter.contentBack")}
</Button>
<Text style={styles.previewPath} numberOfLines={1} ellipsizeMode="head">
{match.path}
</Text>
<Button variant="outline" size="sm" onPress={model.openSelected}>
{t("shell.commandCenter.contentOpen")}
</Button>
</View>
) : null}
{preview.status === "loading" ? (
<View
style={styles.status}
accessibilityRole="progressbar"
accessibilityLabel={t("shell.commandCenter.contentLoadingPreview")}
>
<ThemedSpinner size={14} />
</View>
) : null}
{preview.status === "error" ? (
<View style={styles.status} accessibilityLiveRegion="polite">
<Text style={[styles.muted, styles.error]}>{preview.message}</Text>
<Button variant="outline" size="sm" onPress={model.retryPreview}>
{t("common.actions.retry")}
</Button>
</View>
) : null}
{preview.status === "ready" && location ? (
<WorkspaceFileSource
content={preview.content}
filename={preview.path}
size={preview.size}
navigationRevision={state.activeIndex}
location={location}
/>
) : null}
</View>
);
}
// Denser than the Command Center's own two-line row: these results are scanned, not read, and a
// search wants as many occurrences on screen as stay legible.
const RESULT_ROW_HEIGHT = 44;
// Characters of the matched line kept ahead of the match itself.
const SNIPPET_LEAD = 24;
const styles = StyleSheet.create((theme) => ({
body: { flex: 1, minHeight: 0, flexDirection: "row" },
// A result row is two truncated single lines, so extra panel width buys the list nothing and
// buys the code preview everything: the column is a fixed reading width, capped so a narrow
// desktop panel cannot starve the preview.
results: {
width: 360,
maxWidth: "45%",
minHeight: 0,
borderRightWidth: 1,
borderRightColor: theme.colors.border,
},
full: { width: "100%", maxWidth: "100%", borderRightWidth: 0 },
row: {
height: RESULT_ROW_HEIGHT,
justifyContent: "center",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[1],
},
hovered: { backgroundColor: theme.colors.surface1 },
selected: { backgroundColor: theme.colors.surface2 },
// Three levels: the match is what the reader is looking for, the rest of the line is the
// context it sits in, and the location is context for that.
snippet: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.code,
lineHeight: 17,
fontFamily: theme.fontFamily.mono,
},
match: {
color: theme.colors.foreground,
fontWeight: "600",
backgroundColor: theme.colors.terminal.selectionBackground,
},
location: { flexDirection: "row", alignItems: "baseline" },
locationFile: {
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 15,
},
locationDirectory: {
flexShrink: 1,
minWidth: 0,
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.sm,
lineHeight: 15,
},
preview: { flex: 1, minWidth: 0, minHeight: 0 },
previewHeader: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
gap: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
previewPath: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
status: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[6],
},
muted: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
error: { color: theme.colors.statusDanger },
tooltipPath: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontFamily: theme.fontFamily.mono,
},
empty: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[6],
gap: theme.spacing[1],
},
emptyText: {
textAlign: "center",
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
emptyDetail: {
textAlign: "center",
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
limit: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
@@ -0,0 +1,234 @@
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<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((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<WorkspaceContentSearchResult>();
const second = deferred<WorkspaceContentSearchResult>();
const startedFirst = deferred<void>();
const startedSecond = deferred<void>();
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<void>();
const searched = deferred<WorkspaceContentSearchResult>();
const a = deferred<FileReadResult>();
const c = deferred<FileReadResult>();
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<ReturnType<typeof deferred<FileReadResult>>> = [];
const searches: Array<ReturnType<typeof deferred<WorkspaceContentSearchResult>>> = [];
const model = new WorkspaceContentSearchModel({
cwd: "/workspace",
debounceMs: 0,
open() {},
transport: {
searchWorkspaceContent: () => {
const pending = deferred<WorkspaceContentSearchResult>();
searches.push(pending);
return pending.promise;
},
readFile: () => {
const pending = deferred<FileReadResult>();
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", "<svg>NEEDLE</svg>"),
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: "<svg>NEEDLE</svg>" });
} finally {
model.dispose();
}
});
test("revisiting a file re-reads it, so a file deleted since the search stops previewing", async () => {
const reads: string[] = [];
let deleted = false;
const model = new WorkspaceContentSearchModel({
cwd: "/workspace",
debounceMs: 0,
open() {},
transport: {
searchWorkspaceContent: async () => result([match("a.ts"), match("b.ts")]),
readFile: async (_cwd, path) => {
reads.push(path);
if (path === "a.ts" && deleted) throw new Error("ENOENT: no such file");
return file(path, `NEEDLE in ${path}`);
},
},
});
try {
model.setQuery("needle");
await expect.poll(() => model.getSnapshot().preview.status).toBe("ready");
expect(reads).toEqual(["a.ts"]);
model.move(1);
await expect.poll(() => reads).toEqual(["a.ts", "b.ts"]);
deleted = true;
model.move(-1);
await expect.poll(() => model.getSnapshot().preview).toMatchObject({ status: "error" });
expect(reads).toEqual(["a.ts", "b.ts", "a.ts"]);
} finally {
model.dispose();
}
});
@@ -0,0 +1,206 @@
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<WorkspaceContentSearchResult>;
readFile(
cwd: string,
path: string,
requestId?: string,
maxBytes?: number,
): Promise<FileReadResult>;
}
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;
/** Per-file byte ceiling the host applied, so an empty result can say what it did not read. */
maxFileBytes: number | null;
activeIndex: number;
preview: Preview;
}
/** The daemon refuses to read more, and rg refuses to search a file bigger than this. */
const MAX_PREVIEW_BYTES = 1_048_576;
export class WorkspaceContentSearchModel {
private snapshot: ContentSearchSnapshot = {
query: "",
status: "idle",
message: "",
matches: [],
limited: false,
maxFileBytes: null,
activeIndex: 0,
preview: { status: "empty" },
};
private listeners = new Set<() => void>();
private controller: AbortController | null = null;
private timer: ReturnType<typeof setTimeout> | 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<ContentSearchSnapshot>) {
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,
maxFileBytes: null,
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,
maxFileBytes: result.maxFileBytes ?? null,
});
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;
// Only the file on screen is retained. Re-reading on every arrival is what lets the preview
// tell the truth about a file that changed or disappeared since the search ran; see the
// deleted-file recovery case in the browser spec before trading it for fewer reads.
if (this.file?.path === match.path) {
this.update({ preview: { status: "ready", ...this.file } });
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,
MAX_PREVIEW_BYTES,
);
if (this.disposed) return;
if (query === this.controller) {
const content = explorerFileFromReadResult(file).content;
if (content === undefined) throw new Error("This file is no longer text");
this.file = { path, content, size: file.size };
}
} 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;
};
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { describeResultPath } from "./result-path";
describe("describeResultPath", () => {
it("keeps a root file whole", () => {
expect(describeResultPath("README.md")).toEqual({ directory: "", name: "README.md" });
});
it("keeps a shallow path whole", () => {
expect(describeResultPath("docs/architecture.md")).toEqual({
directory: "docs/",
name: "architecture.md",
});
});
it("elides the head of a deep path and keeps the distinguishing parent", () => {
// These two rows are both "index.tsx"; the parents are the only thing telling them apart.
expect(
describeResultPath("packages/app/src/command-center/workspace-content-search/index.tsx"),
).toEqual({ directory: "…/workspace-content-search/", name: "index.tsx" });
expect(describeResultPath("packages/app/src/file-pane/source/index.tsx")).toEqual({
directory: "…/source/",
name: "index.tsx",
});
});
it("keeps a literal backslash in a file name out of the directory", () => {
expect(describeResultPath("a\\b.txt")).toEqual({ directory: "", name: "a\\b.txt" });
});
});
@@ -0,0 +1,25 @@
/**
* How a result row names its file.
*
* A flat list of occurrences is full of repeated base names — four `index.tsx` rows tell the
* reader nothing. The nearest directories are what distinguish them, and they are also what an
* end-truncated path throws away first. So the row keeps the file name and the closest parents,
* and marks the elided head with a leading ellipsis.
*/
export interface ResultPathParts {
/** Directory context immediately above the file, already elided at the head when deeper. */
directory: string;
/** File name, which is never truncated away. */
name: string;
}
const PARENTS_KEPT = 1;
export function describeResultPath(path: string): ResultPathParts {
const segments = path.split("/");
const name = segments.pop() ?? path;
if (segments.length === 0) return { directory: "", name };
const kept = segments.slice(-PARENTS_KEPT);
const elided = segments.length > kept.length;
return { directory: `${elided ? "…/" : ""}${kept.join("/")}/`, name };
}
@@ -4,12 +4,12 @@ export interface WorkspaceFileSearchEntry {
directory: string;
}
/** Splits a workspace-relative path the host produced, where "/" is the only separator. */
export function describeWorkspaceFilePath(path: string): WorkspaceFileSearchEntry {
const normalized = path.replace(/\\/g, "/");
const separator = normalized.lastIndexOf("/");
const separator = path.lastIndexOf("/");
return {
path: normalized,
name: separator >= 0 ? normalized.slice(separator + 1) : normalized,
directory: separator >= 0 ? normalized.slice(0, separator) : "",
path,
name: separator >= 0 ? path.slice(separator + 1) : path,
directory: separator >= 0 ? path.slice(0, separator) : "",
};
}
@@ -10,11 +10,11 @@ describe("describeWorkspaceFilePath", () => {
});
});
it("normalizes Windows separators before opening or presenting a file", () => {
expect(describeWorkspaceFilePath("src\\components\\message.tsx")).toEqual({
path: "src/components/message.tsx",
name: "message.tsx",
directory: "src/components",
it("treats a backslash as part of the file name, because the host already sent separators as /", () => {
expect(describeWorkspaceFilePath("a\\b.txt")).toEqual({
path: "a\\b.txt",
name: "a\\b.txt",
directory: "",
});
});
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
@@ -128,8 +128,8 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
(path: string) => {
if (!serverId || !workspaceId) return;
clearCommandCenterFocusRestoreElement();
openWorkspaceFileFromExplorer({
filePath: path,
openWorkspaceFileInFocusedPane({
location: { path },
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
closeExplorerAfterOpen: true,
showMobileAgent: usePanelStore.getState().showMobileAgent,
@@ -141,6 +141,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
placement,
}),
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
requestFileNavigation: useWorkspaceLayoutStore.getState().requestFileNavigation,
});
},
[serverId, workspaceId],
@@ -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,
});
}
@@ -14,7 +14,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform";
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
import {
@@ -119,6 +119,7 @@ export function CompactExplorerSidebarHost({
[openTab],
);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const requestFileNavigation = useWorkspaceLayoutStore((state) => state.requestFileNavigation);
const handleOpenExplorer = useCallback(() => {
if (!model?.workspaceRoot) {
@@ -136,16 +137,24 @@ export function CompactExplorerSidebarHost({
if (!model) {
return;
}
openWorkspaceFileFromExplorer({
filePath,
openWorkspaceFileInFocusedPane({
location: { path: filePath },
persistenceKey: model.persistenceKey,
closeExplorerAfterOpen: presentation === "overlay",
showMobileAgent,
openWorkspaceTabInFocusedPane,
focusWorkspaceTab,
requestFileNavigation,
});
},
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
[
focusWorkspaceTab,
model,
openWorkspaceTabInFocusedPane,
presentation,
requestFileNavigation,
showMobileAgent,
],
);
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
@@ -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 " });
});
@@ -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;
@@ -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("<svg>needle</svg>")),
kind: "image" as const,
mime: "image/svg+xml",
path: "icon.svg",
};
expect(explorerFileFromReadResult(file)).toMatchObject({
kind: "image",
content: "<svg>needle</svg>",
});
expect(explorerFileFromReadResult({ ...file, bytes: new Uint8Array([255]) })).toMatchObject({
kind: "image",
content: undefined,
});
});
+11 -2
View File
@@ -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,
+1 -6
View File
@@ -7,6 +7,7 @@ import { SegmentedControl } from "@/components/ui/segmented-control";
import type { Theme } from "@/styles/theme";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { FileEditorStatus } from "./editor/model";
import { formatFileSize } from "@/utils/format-file-size";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -115,12 +116,6 @@ export function FilePanelBar({
);
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
const styles = StyleSheet.create((theme) => ({
chrome: {
flexShrink: 0,
@@ -5,7 +5,6 @@ import {
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { searchKeymap } from "@codemirror/search";
import {
EditorView,
drawSelection,
@@ -42,7 +41,6 @@ export function editorBaseExtensions(onSave: () => void) {
indentWithTab,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
]),
];
}
@@ -63,6 +61,13 @@ export function editorTheme(theme: EditorVisualTheme) {
fontFamily: theme.monoFont,
lineHeight: "1.45",
},
".cm-panels": { backgroundColor: theme.background, color: theme.foreground },
".cm-panels-top": { borderBottom: "none" },
".cm-searchMatch": { backgroundColor: theme.selection },
".cm-searchMatch-selected": {
backgroundColor: theme.selection,
outline: `1px solid ${theme.cursor}`,
},
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
".cm-gutters": {
+36 -16
View File
@@ -1,10 +1,12 @@
import { useEffect, useRef, useSyncExternalStore } from "react";
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";
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";
@@ -38,6 +40,8 @@ export function FileEditorView({
onCursorChange,
onVimModeChange,
}: FileEditorViewProps) {
const { t } = useTranslation();
const [find] = useState(() => new FileFindModel());
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
@@ -54,6 +58,7 @@ export function FileEditorView({
doc: values.content,
extensions: [
vimCompartment.of(values.vimEnabled ? vim() : []),
find.extension,
...editorBaseExtensions(() => void values.model.save()),
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
wrappingCompartment.of(wrappingForFile(values.filename)),
@@ -81,7 +86,7 @@ export function FileEditorView({
view.destroy();
viewRef.current = null;
};
}, []);
}, [find]);
useEffect(() => {
const view = viewRef.current;
@@ -99,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({
@@ -140,16 +142,34 @@ 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 (
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
<div style={FRAME_STYLE}>
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
<FileFind model={find} editor={viewRef} />
</div>
);
}
const remoteUpdate = Annotation.define<boolean>();
// 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,
minWidth: 0,
} as const;
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
@@ -0,0 +1,117 @@
import {
useCallback,
useMemo,
useEffect,
useRef,
useSyncExternalStore,
type RefObject,
} from "react";
import { EditorView } from "@codemirror/view";
import { View, type View as ViewInstance } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { usePaneFocusIfAny } from "@/panels/pane-context";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { FileFindModel } from "./model.web";
export { FileFindModel } from "./model.web";
export function FileFind({
model,
editor,
}: {
model: FileFindModel;
editor: RefObject<EditorView | null>;
}) {
const { t } = useTranslation();
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const widget = useRef<PaneFindHandle>(null);
// A source view can also render outside any pane, such as the Command Center content
// preview. Pane Find belongs to the pane, so there is nothing to open there.
const pane = usePaneFocusIfAny();
const active = useRetainedPanelActive();
// RN Web hands the underlying DOM element to a View ref; the model measures it.
const setWidgetNode = useCallback(
(node: ViewInstance | null) => model.setWidgetNode(node as unknown as HTMLElement | null),
[model],
);
useEffect(() => {
if (!pane?.isInteractive || !active) return;
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "f"
) {
event.preventDefault();
model.open(editor.current);
widget.current?.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [active, editor, model, pane?.isInteractive]);
const replace = useMemo(
() =>
state.readOnly
? undefined
: {
value: state.replacement,
onChange: model.setReplacement,
onReplace: model.replace,
onReplaceAll: model.replaceAll,
},
[model, state.readOnly, state.replacement],
);
if (!pane || !state.open) return null;
const total = `${state.total}${state.limited ? "+" : ""}`;
let status = "";
if (state.query) {
if (state.total === 0) status = t("paneFind.noMatches");
else if (state.current) status = t("paneFind.position", { current: state.current, total });
else status = t("paneFind.total", { total });
}
return (
<View
style={[styles.overlay, state.placement === "top" ? styles.overlayTop : styles.overlayBottom]}
pointerEvents="box-none"
>
<View ref={setWidgetNode} style={styles.widget}>
<PaneFind
ref={widget}
query={state.query}
status={status}
canNavigate={state.total > 0}
onQueryChange={model.setSearch}
onNext={model.next}
onPrevious={model.previous}
onClose={model.close}
replace={replace}
/>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
// The widget floats over the content; the model flips the corner when it would
// otherwise sit on top of the active match.
overlay: {
position: "absolute",
left: theme.spacing[2],
right: theme.spacing[2],
alignItems: "flex-end",
zIndex: 1,
},
// Bounds the widget to the pane; PaneFind's own maxWidth resolves against this.
widget: { maxWidth: "100%" },
overlayTop: { top: theme.spacing[2] },
overlayBottom: { bottom: theme.spacing[2] },
}));
@@ -0,0 +1,255 @@
import { EditorState, type Extension } from "@codemirror/state";
import { EditorView, keymap, type Panel, type ViewUpdate } from "@codemirror/view";
import {
closeSearchPanel,
findNext,
findPrevious,
getSearchQuery,
openSearchPanel,
replaceAll,
replaceNext,
search,
searchKeymap,
SearchQuery,
setSearchQuery,
} from "@codemirror/search";
const MATCH_STATUS_LIMIT = 10_000;
/** Which corner of the editor the floating Find widget occupies. */
export type FindPlacement = "top" | "bottom";
/** CodeMirror owns query, matching, selection, replacement, and panel lifetime. */
export class FileFindModel {
private view: EditorView | null = null;
private listeners = new Set<() => void>();
private matches: Array<{ from: number; to: number }> = [];
private widget: HTMLElement | null = null;
private widgetResize: ResizeObserver | null = null;
private snapshot = {
open: false,
placement: "top" as FindPlacement,
query: "",
replacement: "",
current: 0,
total: 0,
limited: false,
readOnly: true,
};
readonly subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
readonly getSnapshot = () => this.snapshot;
readonly extension: Extension = [
// The widget floats over the editor as a sibling overlay, so this panel is an
// inert placeholder that only reports whether Find is open. Hide the placeholder
// itself, never the shared panel container — Go to line's dialog lives there too.
EditorView.theme({ ".cm-panel.paseo-file-find": { display: "none" } }),
// Reveal matches clear of the widget instead of underneath it.
EditorView.scrollMargins.of((view) => {
const clearance = this.clearance(view);
if (!clearance) return null;
return this.snapshot.placement === "top" ? { top: clearance } : { bottom: clearance };
}),
search({ literal: true, top: true, createPanel: (view) => this.createPanel(view) }),
EditorState.transactionExtender.of((transaction) => {
// CodeMirror seeds every search-opening command from the selection. A
// single-line field cannot represent CR/LF, so keep the previous query.
const multilineSeed = transaction.effects.some(
(effect) => effect.is(setSearchQuery) && /[\r\n]/.test(effect.value.search),
);
if (!multilineSeed) return null;
return { effects: setSearchQuery.of(getSearchQuery(transaction.startState)) };
}),
keymap.of(searchKeymap.filter((binding) => binding.key !== "Mod-f")),
];
readonly open = (view = this.view) => {
if (view) openSearchPanel(view);
};
readonly close = () => {
if (this.view) {
closeSearchPanel(this.view);
this.view.focus();
}
};
readonly next = () => {
if (this.view) findNext(this.view);
};
readonly previous = () => {
if (this.view) findPrevious(this.view);
};
readonly replace = () => {
if (this.view) replaceNext(this.view);
};
readonly replaceAll = () => {
if (this.view) replaceAll(this.view);
};
readonly setReplacement = (replacement: string) =>
this.setQuery(this.snapshot.query, replacement);
readonly setSearch = (query: string) => {
this.setQuery(query, this.snapshot.replacement);
if (this.view && this.matches.length > 0) {
this.view.dispatch({ selection: { anchor: this.view.state.selection.main.from } });
findNext(this.view);
}
};
private setQuery(query: string, replacement: string) {
this.view?.dispatch({
effects: setSearchQuery.of(
new SearchQuery({ search: query, replace: replacement, literal: true }),
),
});
}
/**
* The widget renders outside CodeMirror, so the editor cannot see it. Handing the
* node here lets this model — which already owns reveal — measure the obstruction.
*/
readonly setWidgetNode = (node: HTMLElement | null) => {
if (this.widget === node) return;
this.widgetResize?.disconnect();
this.widgetResize = null;
this.widget = node;
if (!node) return;
this.widgetResize = new ResizeObserver(() => this.reposition());
this.widgetResize.observe(node);
this.reposition();
};
private createPanel(view: EditorView): Panel {
this.view = view;
const dom = document.createElement("div");
dom.className = "paseo-file-find";
return {
dom,
top: true,
mount: () => {
this.snapshot = { ...this.snapshot, open: true, placement: "top" };
this.update(view, true);
},
update: (update: ViewUpdate) => {
const queryChanged = !getSearchQuery(update.startState).eq(getSearchQuery(update.state));
if (
update.docChanged ||
update.selectionSet ||
queryChanged ||
update.geometryChanged ||
update.viewportChanged
)
this.update(view, update.docChanged || queryChanged);
},
destroy: () => {
this.snapshot = { ...this.snapshot, open: false, placement: "top" };
this.publish();
},
};
}
/** Vertical space the widget takes out of the editor, including its inset. */
private clearance(view: EditorView): number {
const widget = this.widget;
if (!widget || !this.snapshot.open) return 0;
const box = widget.getBoundingClientRect();
if (!box.height) return 0;
const editor = view.scrollDOM.getBoundingClientRect();
const clearance =
this.snapshot.placement === "top" ? box.bottom - editor.top : editor.bottom - box.top;
return Math.max(0, clearance);
}
/**
* Move the widget to the opposite corner when it sits on top of the active match
* and the editor has no scroll range left to reveal it. Placement only changes
* when the current corner collides, so navigating matches never makes it bounce.
*
* Measurement runs through `requestMeasure` because CodeMirror refuses layout
* reads while an update is in progress, and every caller here sits inside one.
*/
private reposition() {
const view = this.view;
if (!view || !this.widget || !this.snapshot.open) return;
view.requestMeasure<FindPlacement>({
key: this,
read: () => this.measurePlacement(view),
write: (placement) => {
if (placement === this.snapshot.placement) return;
this.snapshot = { ...this.snapshot, placement };
this.publish();
},
});
}
private measurePlacement(view: EditorView): FindPlacement {
const current = this.snapshot.placement;
const widget = this.widget;
if (!widget) return current;
const selection = view.state.selection.main;
if (selection.empty) return current;
const from = view.coordsAtPos(selection.from);
const to = view.coordsAtPos(selection.to);
if (!from || !to) return current;
const clearance = this.clearance(view);
if (!clearance) return current;
const box = widget.getBoundingClientRect();
const editor = view.scrollDOM.getBoundingClientRect();
const match = {
top: Math.min(from.top, to.top),
bottom: Math.max(from.bottom, to.bottom),
right: Math.max(from.right, to.right),
};
const hits = (placement: FindPlacement) => {
if (match.right <= box.left) return false;
return placement === "top"
? match.top < editor.top + clearance && match.bottom > editor.top
: match.bottom > editor.bottom - clearance && match.top < editor.bottom;
};
if (!hits(current)) return current;
const other: FindPlacement = current === "top" ? "bottom" : "top";
return hits(other) ? current : other;
}
private update(view: EditorView, recount: boolean) {
const query = getSearchQuery(view.state);
let limited = this.snapshot.limited;
if (recount) {
this.matches = [];
limited = false;
if (query.valid) {
const cursor = query.getCursor(view.state);
for (let match = cursor.next(); !match.done; match = cursor.next()) {
if (this.matches.length === MATCH_STATUS_LIMIT) {
limited = true;
break;
}
this.matches.push(match.value);
}
}
}
const selection = view.state.selection.main;
const current =
this.matches.findIndex(
(match) => match.from === selection.from && match.to === selection.to,
) + 1;
this.snapshot = {
...this.snapshot,
query: query.search,
replacement: query.replace,
current,
total: this.matches.length,
limited,
readOnly: view.state.readOnly,
};
this.publish();
this.reposition();
}
private publish() {
for (const listener of this.listeners) listener();
}
}
+8 -44
View File
@@ -33,13 +33,14 @@ import { FileMarkdownPreview } from "./markdown-preview";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
import { FileSourceView } from "./source/view";
import { WorkspaceFileSource } from "./source";
import type { FileConflictAlertState } from "./conflict-alert";
import type { LiveFileModel } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import type { Theme } from "@/styles/theme";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import { formatFileSize } from "@/utils/format-file-size";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -58,24 +59,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`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function ReadonlySource({
preview,
filename,
@@ -87,32 +70,13 @@ function ReadonlySource({
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[theme],
);
return (
<FileSourceView
<WorkspaceFileSource
content={preview.content ?? ""}
filename={filename}
location={location}
navigationRevision={navigationRevision}
size={preview.size}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
@@ -122,7 +86,7 @@ function TooLargeSource({ size }: { size?: number }) {
return (
<View style={styles.centerState} testID="file-source-too-large">
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
{size ? <Text style={styles.binaryMetaText}>{formatFileSize(size)}</Text> : null}
</View>
);
}
@@ -164,7 +128,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 (
@@ -214,7 +178,7 @@ function FilePreviewBody({
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize(preview.size)}</Text>
</View>
);
}
@@ -239,8 +203,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
@@ -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 (
<FileSourceView
{...props}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
@@ -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<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
presentation: "highlighted" | "plain";
}) {
const { t } = useTranslation();
const listRef = useRef<FlatList<SourceLine>>(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 }) => (
<SourceLineView
line={item}
start={!changed && item.number === location.lineStart ? location.columnStart : undefined}
end={!changed && item.number === location.lineStart ? location.columnEnd : undefined}
/>
),
[changed, location],
);
return (
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
renderItem={renderSourceLine}
/>
<View style={styles.root}>
{changed ? (
<Text accessibilityRole="alert" style={styles.notice}>
{t("shell.commandCenter.contentChanged")}
</Text>
) : null}
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
extraData={location}
renderItem={renderLine}
/>
</View>
);
}
function SourceLineView({ line }: { line: SourceLine }) {
function SourceLineView({ line, start, end }: { line: SourceLine; start?: number; end?: number }) {
let offset = 0;
return (
<View style={styles.line}>
<Text style={styles.gutter}>{line.number}</Text>
<Text selectable style={styles.text}>
{line.tokens.map((token) => (
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
{token.text}
</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 (
<Text key={`${tokenStart}:${token.style}`} style={syntaxTokenStyleFor(token.style)}>
{token.text.slice(0, from)}
<Text style={styles.match}>{token.text.slice(from, to)}</Text>
{token.text.slice(to)}
</Text>
);
})}
</Text>
</View>
);
@@ -113,9 +150,14 @@ function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index
return { length: 20, offset: index * 20, index };
}
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={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",
+41 -9
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef } from "react";
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";
@@ -59,6 +61,8 @@ function ReadonlyCodeMirror({
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
presentation: Exclude<SourcePresentation, "unsupported">;
}) {
const { t } = useTranslation();
const [find] = useState(() => new FileFindModel());
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const initial = useRef({ content, filename, presentation, theme });
@@ -71,7 +75,15 @@ function ReadonlyCodeMirror({
state: EditorState.create({
doc: values.content,
extensions: [
find.extension,
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({
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 }),
@@ -85,7 +97,7 @@ function ReadonlyCodeMirror({
view.destroy();
viewRef.current = null;
};
}, []);
}, [find]);
useEffect(() => {
const view = viewRef.current;
@@ -105,12 +117,22 @@ 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 },
// A match far into a long line lands at the scroller edge without the horizontal centre.
effects: EditorView.scrollIntoView(from, { x: "center", y: "center" }),
});
}, [content, location, navigationRevision]);
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
const changed = resolveWorkspaceFileSelection(content.replace(/\r\n?/g, "\n"), location).changed;
return (
<div style={FRAME_STYLE}>
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
<div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />
<FileFind model={find} editor={viewRef} />
</div>
);
}
function languageFor(input: {
@@ -122,6 +144,16 @@ 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,
minWidth: 0,
} as const;
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
const UNSUPPORTED_STYLE = {
alignItems: "center",
+27
View File
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = {
paneFind: {
title: "بحث",
placeholder: "بحث في اللوحة",
close: "إغلاق البحث",
matches: "نتائج البحث",
previous: "التطابق السابق",
next: "التطابق التالي",
toggleReplace: "إظهار الاستبدال",
replaceWith: "استبدال بـ",
replace: "استبدال",
replaceAll: "استبدال الكل",
noMatches: "لا توجد تطابقات",
position: "{{current}} من {{total}}",
total: "{{total}} تطابقات",
},
common: {
back: "خلف",
loading: "تحميل...",
@@ -58,6 +73,18 @@ export const ar: TranslationResources = {
close: "إغلاق القائمة",
},
commandCenter: {
contentTitle: "البحث في محتوى الملفات",
content: "محتوى الملفات",
contentPlaceholder: "البحث في الملفات المحفوظة...",
contentUnavailable: "مساحة العمل غير متاحة",
contentUpdateHost: "حدّث هذا المضيف للبحث في محتوى الملفات",
contentBack: "العودة إلى النتائج",
contentOpen: "فتح",
contentLoadingPreview: "جارٍ تحميل المعاينة...",
contentLimited: "النتائج محدودة — حدّد البحث أكثر",
contentSkippedLargeFiles: "لم يتم البحث في الملفات الأكبر من {{size}}",
contentChanged: "تغيّر الملف — لم تعد المطابقة في هذا الموضع",
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
filePlaceholder: "البحث في الملفات...",
searchingFiles: "جارٍ البحث في الملفات...",
+27
View File
@@ -1,4 +1,19 @@
export const en = {
paneFind: {
title: "Find",
placeholder: "Find in pane",
close: "Close Find",
matches: "Find matches",
previous: "Previous match",
next: "Next match",
toggleReplace: "Toggle replace",
replaceWith: "Replace with",
replace: "Replace",
replaceAll: "Replace all",
noMatches: "No matches",
position: "{{current}} of {{total}}",
total: "{{total}} matches",
},
common: {
back: "Back",
loading: "Loading...",
@@ -55,6 +70,18 @@ export const en = {
close: "Close menu",
},
commandCenter: {
contentTitle: "Search file contents",
content: "File contents",
contentPlaceholder: "Search saved file contents...",
contentUnavailable: "Workspace unavailable",
contentUpdateHost: "Update this host to search file contents",
contentBack: "Back to results",
contentOpen: "Open",
contentLoadingPreview: "Loading preview...",
contentLimited: "Results limited — refine your search",
contentSkippedLargeFiles: "Files over {{size}} were not searched",
contentChanged: "File changed — saved match is no longer at this location",
placeholder: "Search commands, files, workspaces, and agents...",
filePlaceholder: "Search files...",
searchingFiles: "Searching files...",
+27
View File
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const es: TranslationResources = {
paneFind: {
title: "Buscar",
placeholder: "Buscar en el panel",
close: "Cerrar búsqueda",
matches: "Coincidencias",
previous: "Coincidencia anterior",
next: "Siguiente coincidencia",
toggleReplace: "Mostrar reemplazo",
replaceWith: "Reemplazar con",
replace: "Reemplazar",
replaceAll: "Reemplazar todo",
noMatches: "Sin coincidencias",
position: "{{current}} de {{total}}",
total: "{{total}} coincidencias",
},
common: {
back: "Atrás",
loading: "Cargando...",
@@ -58,6 +73,18 @@ export const es: TranslationResources = {
close: "Cerrar menú",
},
commandCenter: {
contentTitle: "Buscar contenido de archivos",
content: "Contenido de archivos",
contentPlaceholder: "Buscar contenido guardado...",
contentUnavailable: "Espacio de trabajo no disponible",
contentUpdateHost: "Actualiza este host para buscar contenido",
contentBack: "Volver a resultados",
contentOpen: "Abrir",
contentLoadingPreview: "Cargando vista previa...",
contentLimited: "Resultados limitados — precisa la búsqueda",
contentSkippedLargeFiles: "No se buscó en archivos de más de {{size}}",
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...",
+27
View File
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const fr: TranslationResources = {
paneFind: {
title: "Rechercher",
placeholder: "Rechercher dans le panneau",
close: "Fermer la recherche",
matches: "Résultats de recherche",
previous: "Résultat précédent",
next: "Résultat suivant",
toggleReplace: "Afficher le remplacement",
replaceWith: "Remplacer par",
replace: "Remplacer",
replaceAll: "Tout remplacer",
noMatches: "Aucun résultat",
position: "{{current}} sur {{total}}",
total: "{{total}} résultats",
},
common: {
back: "Dos",
loading: "Chargement...",
@@ -59,6 +74,18 @@ export const fr: TranslationResources = {
close: "Fermer le menu",
},
commandCenter: {
contentTitle: "Rechercher dans les fichiers",
content: "Contenu des fichiers",
contentPlaceholder: "Rechercher dans les fichiers enregistrés...",
contentUnavailable: "Espace de travail indisponible",
contentUpdateHost: "Mettez cet hôte à jour pour rechercher dans les fichiers",
contentBack: "Retour aux résultats",
contentOpen: "Ouvrir",
contentLoadingPreview: "Chargement de l’aperçu...",
contentLimited: "Résultats limités — précisez la recherche",
contentSkippedLargeFiles: "Les fichiers de plus de {{size}} n’ont pas été analysés",
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...",
+27
View File
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = {
paneFind: {
title: "検索",
placeholder: "ペイン内を検索",
close: "検索を閉じる",
matches: "検索結果",
previous: "前の一致",
next: "次の一致",
toggleReplace: "置換を切り替え",
replaceWith: "置換後の文字列",
replace: "置換",
replaceAll: "すべて置換",
noMatches: "一致なし",
position: "{{current}} / {{total}}",
total: "{{total}} 件の一致",
},
common: {
back: "戻る",
loading: "読み込み中...",
@@ -58,6 +73,18 @@ export const ja: TranslationResources = {
close: "メニューを閉じる",
},
commandCenter: {
contentTitle: "ファイル内容を検索",
content: "ファイル内容",
contentPlaceholder: "保存済みファイルの内容を検索...",
contentUnavailable: "ワークスペースを利用できません",
contentUpdateHost: "ファイル内容を検索するにはホストを更新してください",
contentBack: "検索結果に戻る",
contentOpen: "開く",
contentLoadingPreview: "プレビューを読み込み中...",
contentLimited: "結果が上限に達しました — 検索を絞り込んでください",
contentSkippedLargeFiles: "{{size}} を超えるファイルは検索されていません",
contentChanged: "ファイルが変更され、この位置に一致する内容はありません",
placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...",
filePlaceholder: "ファイルを検索...",
searchingFiles: "ファイルを検索中...",
+27
View File
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = {
paneFind: {
title: "찾기",
placeholder: "패널에서 찾기",
close: "찾기 닫기",
matches: "검색 결과",
previous: "이전 일치 항목",
next: "다음 일치 항목",
toggleReplace: "바꾸기 표시 전환",
replaceWith: "바꿀 내용",
replace: "바꾸기",
replaceAll: "모두 바꾸기",
noMatches: "일치 항목 없음",
position: "{{current}} / {{total}}",
total: "일치 항목 {{total}}개",
},
common: {
back: "뒤로",
loading: "불러오는 중...",
@@ -58,6 +73,18 @@ export const ko: TranslationResources = {
close: "메뉴 닫기",
},
commandCenter: {
contentTitle: "파일 내용 검색",
content: "파일 내용",
contentPlaceholder: "저장된 파일 내용 검색...",
contentUnavailable: "작업 공간을 사용할 수 없음",
contentUpdateHost: "파일 내용을 검색하려면 호스트를 업데이트하세요",
contentBack: "결과로 돌아가기",
contentOpen: "열기",
contentLoadingPreview: "미리보기 로딩 중...",
contentLimited: "결과 제한 — 검색 범위를 좁히세요",
contentSkippedLargeFiles: "{{size}}보다 큰 파일은 검색하지 않았습니다",
contentChanged: "파일이 변경되어 이 위치에 일치하는 내용이 없습니다",
placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...",
filePlaceholder: "파일 검색...",
searchingFiles: "파일 검색 중...",
+27
View File
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ptBR: TranslationResources = {
paneFind: {
title: "Buscar",
placeholder: "Buscar no painel",
close: "Fechar busca",
matches: "Correspondências",
previous: "Correspondência anterior",
next: "Próxima correspondência",
toggleReplace: "Mostrar substituição",
replaceWith: "Substituir por",
replace: "Substituir",
replaceAll: "Substituir tudo",
noMatches: "Nenhuma correspondência",
position: "{{current}} de {{total}}",
total: "{{total}} correspondências",
},
common: {
back: "Voltar",
loading: "Carregando...",
@@ -58,6 +73,18 @@ export const ptBR: TranslationResources = {
close: "Fechar menu",
},
commandCenter: {
contentTitle: "Buscar conteúdo dos arquivos",
content: "Conteúdo dos arquivos",
contentPlaceholder: "Buscar conteúdo salvo...",
contentUnavailable: "Espaço de trabalho indisponível",
contentUpdateHost: "Atualize este host para buscar conteúdo",
contentBack: "Voltar aos resultados",
contentOpen: "Abrir",
contentLoadingPreview: "Carregando prévia...",
contentLimited: "Resultados limitados — refine a busca",
contentSkippedLargeFiles: "Arquivos maiores que {{size}} não foram pesquisados",
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...",
+27
View File
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = {
paneFind: {
title: "Найти",
placeholder: "Найти в панели",
close: "Закрыть поиск",
matches: "Совпадения",
previous: "Предыдущее совпадение",
next: "Следующее совпадение",
toggleReplace: "Показать замену",
replaceWith: "Заменить на",
replace: "Заменить",
replaceAll: "Заменить всё",
noMatches: "Нет совпадений",
position: "{{current}} из {{total}}",
total: "Совпадений: {{total}}",
},
common: {
back: "Назад",
loading: "Загрузка...",
@@ -58,6 +73,18 @@ export const ru: TranslationResources = {
close: "Закрыть меню",
},
commandCenter: {
contentTitle: "Поиск по содержимому файлов",
content: "Содержимое файлов",
contentPlaceholder: "Поиск в сохранённых файлах...",
contentUnavailable: "Рабочее пространство недоступно",
contentUpdateHost: "Обновите этот хост для поиска по содержимому",
contentBack: "Назад к результатам",
contentOpen: "Открыть",
contentLoadingPreview: "Загрузка предпросмотра...",
contentLimited: "Результаты ограничены — уточните поиск",
contentSkippedLargeFiles: "Файлы больше {{size}} не искались",
contentChanged: "Файл изменён — совпадения больше нет в этом месте",
placeholder: "Поиск команд, файлов, рабочих пространств и агентов...",
filePlaceholder: "Поиск файлов...",
searchingFiles: "Поиск файлов...",
+27
View File
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = {
paneFind: {
title: "查找",
placeholder: "在窗格中查找",
close: "关闭查找",
matches: "查找结果",
previous: "上一个匹配项",
next: "下一个匹配项",
toggleReplace: "切换替换",
replaceWith: "替换为",
replace: "替换",
replaceAll: "全部替换",
noMatches: "无匹配项",
position: "{{current}} / {{total}}",
total: "{{total}} 个匹配项",
},
common: {
back: "返回",
loading: "加载中...",
@@ -58,6 +73,18 @@ export const zhCN: TranslationResources = {
close: "关闭菜单",
},
commandCenter: {
contentTitle: "搜索文件内容",
content: "文件内容",
contentPlaceholder: "搜索已保存的文件内容...",
contentUnavailable: "工作区不可用",
contentUpdateHost: "请更新此主机以搜索文件内容",
contentBack: "返回结果",
contentOpen: "打开",
contentLoadingPreview: "正在加载预览...",
contentLimited: "结果已达上限 — 请缩小搜索范围",
contentSkippedLargeFiles: "未搜索大于 {{size}} 的文件",
contentChanged: "文件已更改 — 此位置不再匹配",
placeholder: "搜索命令、文件、工作区和 Agent...",
filePlaceholder: "搜索文件...",
searchingFiles: "正在搜索文件...",
+1
View File
@@ -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"
@@ -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();
});
@@ -156,6 +156,7 @@ export const SHORTCUT_HELP_ROW_ORDER: Record<ShortcutSectionId, readonly string[
general: [
"toggle-command-center",
"search-files",
"search-workspace-content",
"show-shortcuts",
"toggle-settings",
"cycle-theme",
@@ -239,6 +240,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"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",
+5 -1
View File
@@ -32,7 +32,7 @@ export type ShortcutAction =
| { kind: "router-push"; route: string }
| { kind: "open-project-picker" }
| { kind: "callback"; name: ShortcutCallbackName }
| { kind: "command-center-toggle"; nextOpen: boolean; 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" };
+363
View File
@@ -0,0 +1,363 @@
import {
forwardRef,
useCallback,
useLayoutEffect,
useMemo,
useImperativeHandle,
useRef,
useState,
type ReactNode,
type KeyboardEvent,
} from "react";
import {
Text,
View,
type NativeSyntheticEvent,
type TextInputKeyPressEventData,
type TextInputProps,
} from "react-native";
import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input";
import {
createControlGeometry,
resolveControlInteractionStyles,
} from "@/components/ui/control-geometry";
import {
mutedIconColorMapping,
smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome";
import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
const TextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ArrowUpIcon = withUnistyles(ArrowUp, mutedIconColorMapping);
const ArrowDownIcon = withUnistyles(ArrowDown, mutedIconColorMapping);
const CloseIcon = withUnistyles(X, mutedIconColorMapping);
const ChevronDownIcon = withUnistyles(ChevronDown, mutedIconColorMapping);
const ChevronRightIcon = withUnistyles(ChevronRight, mutedIconColorMapping);
export interface PaneFindHandle {
focus(): void;
}
export interface PaneFindProps {
query: string;
status: string;
canNavigate: boolean;
onQueryChange(query: string): void;
onNext(): void;
onPrevious(): void;
onClose(): void;
replace?: {
value: string;
onChange(value: string): void;
onReplace(): void;
onReplaceAll(): void;
};
}
/**
* The bordered box around one Find input. It owns the field chrome so the query
* row and the replacement row land on the same rails, and so the match count can
* sit inside the query box instead of widening the widget.
*/
const FindField = forwardRef<
EditingTextInputHandle,
{
label: string;
initialValue: string;
height: number;
trailing?: ReactNode;
onChangeText(value: string): void;
onKeyPress(event: NativeSyntheticEvent<TextInputKeyPressEventData>): void;
autoFocus?: boolean;
returnKeyType?: TextInputProps["returnKeyType"];
}
>(function FindField(
{ label, initialValue, height, trailing, onChangeText, onKeyPress, autoFocus, returnKeyType },
ref,
) {
const [focused, setFocused] = useState(false);
const onFocus = useCallback(() => setFocused(true), []);
const onBlur = useCallback(() => setFocused(false), []);
const fieldStyle = useMemo(
() => [
styles.field,
{ minHeight: height },
resolveControlInteractionStyles(
{
controlRest: styles.controlRest,
controlHover: styles.controlHover,
controlActive: styles.controlActive,
},
{ focused },
),
],
[focused, height],
);
return (
<View style={fieldStyle}>
<TextInput
ref={ref}
autoFocus={autoFocus}
selectTextOnFocus
initialValue={initialValue}
onChangeText={onChangeText}
onKeyPress={onKeyPress}
onFocus={onFocus}
onBlur={onBlur}
accessibilityLabel={label}
placeholder={label}
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
returnKeyType={returnKeyType}
style={styles.input}
/>
{trailing}
</View>
);
});
/** Pane-local chrome. The content owner supplies search state and commands. */
export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneFind(
{ query, status, canNavigate, onQueryChange, onNext, onPrevious, onClose, replace },
ref,
) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const controlSize = smallIconButtonChromeFrameSize(isCompact);
const glyphSize = paneContentToolbarIconSize(isCompact);
const rowHeight = Math.max(COLLAPSED_FIELD_HEIGHT, controlSize);
// Keep the replace actions on the same row height as the icon controls beside them.
const actionSize = isCompact ? "sm" : "xs";
const input = useRef<EditingTextInputHandle>(null);
const replacementInput = useRef<EditingTextInputHandle>(null);
useLayoutEffect(() => {
if (input.current?.getText() !== query) input.current?.replaceText(query);
}, [query]);
useLayoutEffect(() => {
if (replace && replacementInput.current?.getText() !== replace.value)
replacementInput.current?.replaceText(replace.value);
}, [replace]);
const [replaceExpanded, setReplaceExpanded] = useState(false);
const focus = useCallback(() => {
input.current?.focus();
const text = input.current?.getText() ?? "";
input.current?.replaceText(text, { start: 0, end: text.length });
}, []);
useImperativeHandle(ref, () => ({ focus }), [focus]);
const onKeyPress = useCallback(
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
const key = event.nativeEvent as TextInputKeyPressEventData & {
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
isComposing?: boolean;
keyCode?: number;
};
if (isImeComposingKeyboardEvent(key)) return;
if (
(key.metaKey || key.ctrlKey) &&
!key.altKey &&
!key.shiftKey &&
key.key.toLowerCase() === "f"
) {
// RN Web inputs stop keydown before the pane's document listener.
event.preventDefault();
event.stopPropagation();
focus();
} else if (key.key === "Escape" || key.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (key.key === "Escape") onClose();
else if (key.shiftKey) onPrevious();
else onNext();
}
},
[focus, onClose, onNext, onPrevious],
);
const toggleReplace = useCallback(() => setReplaceExpanded((expanded) => !expanded), []);
const gutterStyle = useMemo(
() => [styles.gutter, { width: controlSize, height: rowHeight }],
[controlSize, rowHeight],
);
const matchCount = useMemo(
() => (
<Text
style={styles.status}
role="status"
accessibilityLabel={t("paneFind.matches")}
accessibilityLiveRegion="polite"
>
{status}
</Text>
),
[status, t],
);
return (
<View
style={styles.widget}
accessibilityLabel={t("paneFind.title")}
{...(isWeb
? {
onKeyDown: (event: KeyboardEvent) => {
if (event.key === "Escape" && !isImeComposingKeyboardEvent(event.nativeEvent)) {
event.preventDefault();
event.stopPropagation();
onClose();
}
},
}
: {})}
>
{replace ? (
<View style={gutterStyle}>
<ToolbarButton
label={t("paneFind.toggleReplace")}
aria-expanded={replaceExpanded}
compact={isCompact}
onPress={toggleReplace}
>
{replaceExpanded ? (
<ChevronDownIcon size={glyphSize} />
) : (
<ChevronRightIcon size={glyphSize} />
)}
</ToolbarButton>
</View>
) : null}
<View style={styles.rows}>
<View style={styles.row}>
<FindField
ref={input}
autoFocus
label={t("paneFind.placeholder")}
initialValue={query}
height={rowHeight}
returnKeyType="search"
trailing={matchCount}
onChangeText={onQueryChange}
onKeyPress={onKeyPress}
/>
<ToolbarButton
label={t("paneFind.previous")}
compact={isCompact}
disabled={!canNavigate}
onPress={onPrevious}
>
<ArrowUpIcon size={glyphSize} />
</ToolbarButton>
<ToolbarButton
label={t("paneFind.next")}
compact={isCompact}
disabled={!canNavigate}
onPress={onNext}
>
<ArrowDownIcon size={glyphSize} />
</ToolbarButton>
<ToolbarButton label={t("paneFind.close")} compact={isCompact} onPress={onClose}>
<CloseIcon size={glyphSize} />
</ToolbarButton>
</View>
{replace && replaceExpanded ? (
<View style={styles.row}>
<FindField
ref={replacementInput}
label={t("paneFind.replaceWith")}
initialValue={replace.value}
height={rowHeight}
onChangeText={replace.onChange}
onKeyPress={onKeyPress}
/>
<Button
variant="ghost"
size={actionSize}
style={styles.replaceAction}
disabled={!canNavigate}
onPress={replace.onReplace}
>
{t("paneFind.replace")}
</Button>
<Button
variant="ghost"
size={actionSize}
style={styles.replaceAction}
disabled={!canNavigate}
onPress={replace.onReplaceAll}
>
{t("paneFind.replaceAll")}
</Button>
</View>
) : null}
</View>
</View>
);
});
/** Desktop find rows run one step tighter than a form field — this is pane chrome. */
const COLLAPSED_FIELD_HEIGHT = 28;
const FIND_WIDGET_WIDTH = 340;
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
widget: {
width: FIND_WIDGET_WIDTH,
maxWidth: "100%",
flexDirection: "row",
padding: theme.spacing[1.5],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
...theme.shadow.md,
},
// The disclosure column spans both rows so the two fields share a leading rail.
gutter: { alignItems: "center", justifyContent: "center", flexShrink: 0 },
rows: { flex: 1, minWidth: 0, gap: theme.spacing[1] },
row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1] },
field: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
},
controlRest: { ...geometry.controlRest },
controlHover: { ...geometry.controlHover },
controlActive: { ...geometry.controlActive },
input: {
flex: 1,
minWidth: 0,
paddingHorizontal: 0,
paddingVertical: 0,
color: theme.colors.foreground,
outlineWidth: 0,
outlineColor: "transparent",
...geometry.fieldTextSm,
},
status: {
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
// Ghost actions sit on the field's rail, so they carry the field's padding.
replaceAction: { paddingHorizontal: theme.spacing[2] },
};
});
+6 -1
View File
@@ -75,7 +75,12 @@ export function usePaneContext(): PaneContextValue {
}
export function usePaneFocus(): PaneFocusContextValue {
const value = useContext(PaneFocusContext);
const value = usePaneFocusIfAny();
invariant(value, "PaneFocusContext is required");
return value;
}
/** Pane focus for subtrees that a pane may or may not own, such as an overlay preview. */
export function usePaneFocusIfAny(): PaneFocusContextValue | null {
return useContext(PaneFocusContext);
}
@@ -1,22 +1,24 @@
import { describe, expect, it, vi } from "vitest";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
function createInput(closeExplorerAfterOpen: boolean) {
return {
filePath: "src/app.tsx",
location: { path: "src/app.tsx" } as WorkspaceFileLocation,
persistenceKey: "server:workspace",
closeExplorerAfterOpen,
showMobileAgent: vi.fn(),
openWorkspaceTabInFocusedPane: vi.fn(() => "file-tab"),
focusWorkspaceTab: vi.fn(),
requestFileNavigation: vi.fn(),
};
}
describe("openWorkspaceFileFromExplorer", () => {
describe("openWorkspaceFileInFocusedPane", () => {
it("closes the phone overlay after opening a file", () => {
const input = createInput(true);
openWorkspaceFileFromExplorer(input);
openWorkspaceFileInFocusedPane(input);
expect(input.showMobileAgent).toHaveBeenCalledOnce();
});
@@ -24,10 +26,29 @@ describe("openWorkspaceFileFromExplorer", () => {
it("keeps the tablet dock open after opening a file", () => {
const input = createInput(false);
openWorkspaceFileFromExplorer(input);
openWorkspaceFileInFocusedPane(input);
expect(input.showMobileAgent).not.toHaveBeenCalled();
expect(input.openWorkspaceTabInFocusedPane).toHaveBeenCalledOnce();
expect(input.focusWorkspaceTab).toHaveBeenCalledWith("server:workspace", "file-tab");
});
it("asks for navigation every time, so reopening the same occurrence recentres its pane", () => {
const input = createInput(false);
input.location = { path: "src/app.tsx", lineStart: 1201, columnStart: 17 };
openWorkspaceFileInFocusedPane(input);
openWorkspaceFileInFocusedPane(input);
expect(input.requestFileNavigation).toHaveBeenCalledTimes(2);
expect(input.requestFileNavigation).toHaveBeenLastCalledWith("file-tab");
});
it("does not navigate when no tab could be opened", () => {
const input = { ...createInput(false), openWorkspaceTabInFocusedPane: vi.fn(() => null) };
openWorkspaceFileInFocusedPane(input);
expect(input.requestFileNavigation).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,7 @@
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type WorkspaceFileLocation,
} from "@/workspace/file-open";
import {
FOCUSED_PANE_PLACEMENT,
@@ -8,8 +9,8 @@ import {
} from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
interface OpenWorkspaceFileFromExplorerInput {
filePath: string;
interface OpenWorkspaceFileInput {
location: WorkspaceFileLocation;
persistenceKey: string | null;
closeExplorerAfterOpen: boolean;
showMobileAgent: () => void;
@@ -19,16 +20,24 @@ interface OpenWorkspaceFileFromExplorerInput {
placement?: WorkspaceTabPlacement,
) => string | null;
focusWorkspaceTab: (workspaceKey: string, tabId: string) => void;
requestFileNavigation: (tabId: string) => void;
}
export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplorerInput): void {
/**
* Opens a workspace file in the focused pane and navigates to it.
*
* The navigation request is the part callers keep forgetting: a file target is identity-stable, so
* reopening the same path — or the same occurrence — reuses its tab and would otherwise leave the
* pane wherever the reader had scrolled it. Every explicit activation asks for a reveal.
*/
export function openWorkspaceFileInFocusedPane(input: OpenWorkspaceFileInput): void {
if (input.closeExplorerAfterOpen) {
input.showMobileAgent();
}
if (!input.persistenceKey) {
return;
}
const location = normalizeWorkspaceFileLocation({ path: input.filePath });
const location = normalizeWorkspaceFileLocation(input.location);
if (!location) {
return;
}
@@ -37,7 +46,9 @@ export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplor
createWorkspaceFileTabTarget(location),
FOCUSED_PANE_PLACEMENT,
);
if (tabId) {
input.focusWorkspaceTab(input.persistenceKey, tabId);
if (!tabId) {
return;
}
input.focusWorkspaceTab(input.persistenceKey, tabId);
input.requestFileNavigation(tabId);
}
@@ -1647,17 +1647,13 @@ function WorkspaceScreenContent({
) => openTab({ workspaceKey, target, intent: "reveal", parentTabId, placement }),
[openTab],
);
// File targets stay identity-stable so the same path reuses its tab. Keep navigation
// requests separate so clicking an unchanged path:line can still recenter the pane.
const [fileNavigationRevisionByTabId, setFileNavigationRevisionByTabId] = useState<
Record<string, number>
>({});
const requestFileNavigation = useCallback((tabId: string) => {
setFileNavigationRevisionByTabId((current) => ({
...current,
[tabId]: (current[tabId] ?? 0) + 1,
}));
}, []);
// File targets stay identity-stable so the same path reuses its tab. Navigation requests live
// with the tabs in the layout store, so an opener outside this screen — the Command Center —
// asks for the same reveal through the same capability.
const fileNavigationRevisionByTabId = useWorkspaceLayoutStore(
(state) => state.fileNavigationRevisionByTabId,
);
const requestFileNavigation = useWorkspaceLayoutStore((state) => state.requestFileNavigation);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
@@ -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;
@@ -110,6 +110,12 @@ interface WorkspaceLayoutStore {
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
/**
* Bumped whenever a file tab is explicitly activated. File targets are identity-stable so the
* same path reuses its tab; without this an activation of an unchanged location would look like
* a no-op and leave the pane wherever the reader had scrolled it.
*/
fileNavigationRevisionByTabId: Record<string, number>;
explorerSidebarPaneIdByWorkspace: Record<string, string | null>;
sidePaneIdByWorkspace: Record<string, string | null>;
openTab: (input: OpenWorkspaceTabInput) => string | null;
@@ -120,6 +126,7 @@ interface WorkspaceLayoutStore {
ensureSidePane: (workspaceKey: string) => string | null;
closeTab: (workspaceKey: string, tabId: string) => void;
focusTab: (workspaceKey: string, tabId: string) => void;
requestFileNavigation: (tabId: string) => void;
selectTabInPane: (workspaceKey: string, paneId: string, tabId: string) => void;
replaceTab: (
workspaceKey: string,
@@ -207,6 +214,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"),
@@ -767,6 +777,7 @@ export function createWorkspaceLayoutStore(
pendingAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
fileNavigationRevisionByTabId: {},
explorerSidebarPaneIdByWorkspace: {},
sidePaneIdByWorkspace: {},
openTab: (input) => {
@@ -1040,6 +1051,16 @@ export function createWorkspaceLayoutStore(
};
});
},
requestFileNavigation: (tabId) => {
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedTabId) return;
set((state) => ({
fileNavigationRevisionByTabId: {
...state.fileNavigationRevisionByTabId,
[normalizedTabId]: (state.fileNavigationRevisionByTabId[normalizedTabId] ?? 0) + 1,
},
}));
},
focusTab: (workspaceKey, tabId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId);
@@ -0,0 +1,6 @@
/** Renders a byte count in the units the file pane and file search already show. */
export function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
+13
View File
@@ -1,3 +1,16 @@
export function isAbsolutePath(value: string): boolean {
return value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
}
/**
* Rewrites separators only for a path that is Windows-shaped, meaning it carries a drive letter
* or a UNC prefix. Everywhere else a backslash is an ordinary file name character, so a POSIX or
* workspace-relative path is returned untouched.
*/
export function normalizePathSeparators(value: string): string {
return isWindowsShapedPath(value) ? value.replace(/\\/g, "/") : value;
}
function isWindowsShapedPath(value: string): boolean {
return value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
resolveWorkspaceFileSelection,
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
resolveWorkspaceFilePaths,
@@ -7,10 +8,10 @@ import {
} from ".";
describe("normalizeWorkspaceFileLocation", () => {
it("normalizes paths and valid line ranges", () => {
it("clamps line ranges and leaves the path as the producer gave it", () => {
expect(
normalizeWorkspaceFileLocation({
path: "src\\app.ts",
path: "src/app.ts",
lineStart: 12.8,
lineEnd: 20.2,
}),
@@ -34,7 +35,7 @@ describe("normalizeWorkspaceFileLocation", () => {
});
it("rejects empty paths", () => {
expect(normalizeWorkspaceFileLocation({ path: " " })).toBeNull();
expect(normalizeWorkspaceFileLocation({ path: "" })).toBeNull();
});
});
@@ -97,13 +98,33 @@ describe("resolveWorkspaceFilePaths", () => {
});
});
it("normalizes Windows separators in the file path", () => {
it("normalizes separators in a Windows-shaped path and leaves every other path literal", () => {
expect(
resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }),
resolveWorkspaceFilePaths({
path: "C:\\Users\\me\\repo\\src\\app.ts",
workspaceRoot: "C:\\Users\\me\\repo",
}),
).toEqual({
absolutePath: "/Users/me/repo/src/app.ts",
absolutePath: "C:/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
// A workspace-relative path is the host's own identity, so its backslash is a file name
// character. Rewriting it here is what opened a different file than the search result.
expect(
resolveWorkspaceFilePaths({ path: "a\\b.txt", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "/Users/me/repo/a\\b.txt",
relativePath: "a\\b.txt",
});
expect(
resolveWorkspaceFilePaths({
path: "/Users/me/repo/a\\b.txt",
workspaceRoot: "/Users/me/repo",
}),
).toEqual({
absolutePath: "/Users/me/repo/a\\b.txt",
relativePath: "a\\b.txt",
});
});
it("normalizes dot segments in workspace-relative paths", () => {
@@ -183,3 +204,50 @@ 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 ",
});
});
it("preserves a literal backslash file name through location identity", () => {
const location = { path: "a\\b.txt", lineStart: 1, columnStart: 1, expectedText: "needle" };
expect(normalizeWorkspaceFileLocation(location)).toEqual(location);
});
+49 -5
View File
@@ -1,11 +1,18 @@
import { isAbsolutePath } from "@/utils/path";
import { isAbsolutePath, normalizePathSeparators } from "@/utils/path";
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,25 @@ export function normalizeWorkspaceFileLocation(
return null;
}
const path = location.path.trim().replace(/\\/g, "/");
// The path is an identity the host already produced, so it is kept verbatim: a backslash is a
// file name character outside Windows, and rewriting it here opened a different file than the
// one a search result or explorer row named. Callers holding a path scraped out of text
// normalize it themselves before arriving.
const path = location.path;
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 +57,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 +166,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 = normalizePathSeparators(input.path);
const workspaceRoot = normalizeAbsolutePath(normalizePathSeparators(input.workspaceRoot));
if (!filePath || !workspaceRoot) {
return null;
}
@@ -176,3 +197,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 };
}
+28
View File
@@ -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<WorkspaceContentSearchResult> {
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,
+1
View File
@@ -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 {
+56
View File
@@ -2688,6 +2688,57 @@ 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(),
/**
* Per-file byte ceiling the host applied. Files above it are never opened, so a result set
* is complete only for files at or under this size. Absent on hosts that do not report it.
*/
// COMPAT(workspaceContentSearchMaxFileBytes): added in v0.8.0, remove optionality after
// 2027-03-10 once the daemon floor reports it.
maxFileBytes: z.number().int().positive().optional(),
}),
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<typeof WorkspaceContentMatchSchema>;
export type WorkspaceContentSearchResult = z.infer<typeof WorkspaceContentSearchResultSchema>;
export const FileSubscribeRequestSchema = z.object({
type: z.literal("fs.file.subscribe.request"),
cwd: z.string(),
@@ -3211,6 +3262,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
WorkspaceClearAttentionRequestSchema,
WorkspaceMarkUnreadRequestSchema,
FileExplorerRequestSchema,
WorkspaceContentSearchRequestSchema,
WorkspaceContentCancelRequestSchema,
FileSubscribeRequestSchema,
FileUnsubscribeRequestSchema,
FileWriteRequestSchema,
@@ -3516,6 +3569,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 +6671,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeArchiveResponseSchema,
CreatePaseoWorktreeResponseSchema,
FileExplorerResponseSchema,
WorkspaceContentSearchResponseSchema,
WorkspaceContentCancelResponseSchema,
FileSubscribeResponseSchema,
FileUnsubscribeResponseSchema,
FileWriteResponseSchema,
@@ -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",
@@ -0,0 +1,42 @@
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" });
// A complete empty result also reports the per-file ceiling the host applied, so the client
// can say which files it never opened rather than claiming it read the whole workspace.
expect(await client.searchWorkspaceContent({ cwd, query: "missing" })).toEqual({
status: "ok",
matches: [],
limited: false,
maxFileBytes: 1_048_576,
});
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);
@@ -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 });
}
});
@@ -2,7 +2,7 @@ import { constants, promises as fs, type BigIntStats, type Stats } from "fs";
import type { FileHandle } from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import { expandUserPath, resolvePathFromBase, toWorkspaceRelativePath } from "../path-utils.js";
import { runGitCommand } from "../../utils/run-git-command.js";
export type ExplorerEntryKind = "file" | "directory";
@@ -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<FileExplorerFile> {
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<FileExplorerFileBytes> {
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<void>,
): Promise<void> {
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();
@@ -861,7 +871,7 @@ function normalizeRelativePath({ root, targetPath }: { root: string; targetPath:
const normalizedRoot = expandUserPath(root);
const normalizedTarget = expandUserPath(targetPath);
const relative = path.relative(normalizedRoot, normalizedTarget);
return relative === "" ? "." : relative.split(path.sep).join("/");
return relative === "" ? "." : toWorkspaceRelativePath(relative);
}
function textMimeTypeForExtension(ext: string): string {
@@ -0,0 +1,18 @@
import { sep } from "node:path";
import { expect, test } from "vitest";
import { toWorkspaceRelativePath } from "./path-utils.js";
// Workspace-relative identities cross the wire with "/" whatever the host separator is, so a
// client never has to guess which backslash was a separator and which was a file name character.
test("renders a host-relative path with forward slashes", () => {
expect(toWorkspaceRelativePath(["src", "app.ts"].join(sep))).toBe("src/app.ts");
expect(toWorkspaceRelativePath("app.ts")).toBe("app.ts");
});
test.skipIf(sep === "\\")("leaves a POSIX file name containing a backslash intact", () => {
expect(toWorkspaceRelativePath("a\\b.txt")).toBe("a\\b.txt");
});
test.skipIf(sep !== "\\")("converts every Windows separator", () => {
expect(toWorkspaceRelativePath("src\\nested\\app.ts")).toBe("src/nested/app.ts");
});
+16 -9
View File
@@ -1,5 +1,5 @@
import { homedir } from "node:os";
import { isAbsolute, posix, resolve, win32 } from "node:path";
import { isAbsolute, posix, resolve, sep, win32 } from "node:path";
export function assertAbsolutePath(cwd: string): void {
if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) {
@@ -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 {
@@ -40,3 +38,12 @@ export function isSameOrDescendantPath(basePath: string, candidatePath: string):
normalizedCandidate === normalizedBase || normalizedCandidate.startsWith(normalizedBase + "/")
);
}
/**
* Renders a host-relative path the way every workspace-relative identity crosses the wire: this
* host's separator becomes "/", and every other character survives, so a Unix file name that
* contains a backslash stays that file rather than becoming a directory on the client.
*/
export function toWorkspaceRelativePath(relative: string): string {
return relative.split(sep).join("/");
}
+5
View File
@@ -2624,6 +2624,11 @@ export class Session {
source?: object,
): Promise<void> | 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":
@@ -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<string, AbortController>();
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<SessionInboundMessage, { type: "fs.content.search.request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "fs.content.cancel.request" }>,
): 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<void> {
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<void> {
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<void> {
const { cwd: workspaceCwd, path: requestedPath, requestId } = request;
const cwd = workspaceCwd.trim();
const cwd = workspaceCwd;
if (!cwd) {
this.host.emit({
type: "file_download_token_response",
@@ -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.
@@ -0,0 +1,227 @@
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<string, string>) {
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,
maxFileBytes: 1_048_576,
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,
maxFileBytes: 1_048_576,
});
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]]);
});
test("states the per-file ceiling so a search that skipped an oversized file is not reported as complete", async () => {
const cwd = await workspace({ "large.txt": `needle${" ".repeat(1_048_576)}` });
expect(await searchWorkspaceContent({ cwd, query: "needle" })).toEqual({
status: "ok",
matches: [],
limited: false,
maxFileBytes: 1_048_576,
});
await writeFile(path.join(cwd, "small.txt"), "needle");
const withSmall = await searchWorkspaceContent({ cwd, query: "needle" });
expect(
withSmall.status === "ok" && [withSmall.matches.map((m) => m.path), withSmall.maxFileBytes],
).toEqual([["small.txt"], 1_048_576]);
});
// A backslash is an ordinary character in a Unix file name but a separator on Windows, so this
// file name only exists on POSIX. Windows separator handling is covered by toWorkspaceRelativePath,
// which runs on every platform.
test.skipIf(process.platform === "win32")(
"keeps a literal backslash in a saved file name exactly as the host reported it",
async () => {
const cwd = await workspace({ "a\\b.txt": "needle" });
const result = await searchWorkspaceContent({ cwd, query: "needle" });
expect(result.status === "ok" && result.matches.map((match) => match.path)).toEqual([
"a\\b.txt",
]);
},
);
test("converts coordinates in one pass, so a high-line-count file stays inside the budget", async () => {
// A supported saved shape: a million empty lines, then 200 literal matches. Converting each
// occurrence by decoding and splitting everything before it blocked for ~5.9s and answered "ok"
// after the five-second budget had already passed.
const timeoutMs = 5000;
const cwd = await workspace({
"sparse.txt": `${"\n".repeat(1_000_000)}${"needle\n".repeat(200)}`,
});
const started = Date.now();
const result = await searchWorkspaceContent({ cwd, query: "needle" }, { timeoutMs });
const elapsed = Date.now() - started;
expect(result.status === "ok" && result.matches.length).toBe(200);
expect(result.status === "ok" && result.matches[0]).toMatchObject({
path: "sparse.txt",
line: 1_000_001,
columnStart: 1,
columnEnd: 7,
text: "needle",
snippet: "needle",
});
expect(result.status === "ok" && result.matches[199]).toMatchObject({ line: 1_000_200 });
// A successful answer must arrive inside the budget it claims to honour.
expect(elapsed, `succeeded after ${elapsed}ms against a ${timeoutMs}ms budget`).toBeLessThan(
timeoutMs,
);
// And with room to spare: the conversion is meant to be proportional to the file, not to the
// file times its occurrences.
expect(elapsed, `conversion took ${elapsed}ms`).toBeLessThan(2000);
});
test("stops converting when the deadline passes instead of answering late", async () => {
const cwd = await workspace({ "many.txt": `${"needle\n".repeat(200)}` });
expect(await searchWorkspaceContent({ cwd, query: "needle" }, { timeoutMs: 0 })).toMatchObject({
status: "error",
code: "timeout",
});
});
@@ -0,0 +1,345 @@
import { stat } from "node:fs/promises";
import { sep } from "node:path";
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";
import { toWorkspaceRelativePath } from "../path-utils.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<WorkspaceContentSearchResult> {
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, maxFileBytes: MAX_FILE_BYTES };
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<typeof setTimeout> | 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 timedOut = () => {
error = failure("timeout", "Search timed out — refine your query or retry");
stop();
};
// Reading and converting each matched file is synchronous work between ticks, so the timer
// alone cannot end an operation that overruns inside it. Conversion checks the same deadline
// directly, and the budget covers the whole operation rather than only its search.
const deadline = Date.now() + (options.timeoutMs ?? 5000);
const timeout = setTimeout(timedOut, 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 = workspacePathFromRg(event.data.path?.text);
} 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), () => {
if (!error && Date.now() >= deadline) timedOut();
return !!error;
});
} catch {
error ??= failure("unavailable", "Files changed while searching — retry");
}
cleanup();
resolve(error ?? { status: "ok", matches: located, limited, maxFileBytes: MAX_FILE_BYTES });
});
});
}
/** rg reports paths under the "." root using this host's separator; identities cross the wire
* with "/" so a client never has to guess which character was a separator. */
function workspacePathFromRg(reported: string | undefined): string {
if (!reported) return "";
const relative = reported.startsWith(`.${sep}`) ? reported.slice(2) : reported;
return toWorkspaceRelativePath(relative);
}
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<WorkspaceContentSearchResult, { status: "error" }>["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;
}
const LINE_FEED = 0x0a;
const CARRIAGE_RETURN = 0x0d;
/**
* Walks a file's saved bytes once, handing out each occurrence's source line and UTF-16 column.
*
* rg counts LF records while source views count CR, CRLF and LF lines, so the offsets it reports
* have to be translated. Doing that by decoding and splitting everything before each occurrence
* costs the whole file per match: a million-line file with 200 matches blocked for about six
* seconds and answered after its own deadline. Occurrences arrive in ascending offset order, so a
* single forward cursor answers all of them, and each line is decoded once however many matches
* it holds.
*/
function createLineReader(bytes: Uint8Array) {
const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
// A leading byte-order mark is not part of the first line's text and is not counted in its
// columns. Stripping it once here keeps every later decode positional.
const start = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0;
let cursor = start;
let line = 1;
let lineStart = start;
let lineEnd = -1;
let lineText = "";
let columnByte = 0;
let column = 0;
function endOfLine(from: number): number {
let index = from;
while (index < bytes.length && bytes[index] !== LINE_FEED && bytes[index] !== CARRIAGE_RETURN) {
index += 1;
}
return index;
}
return function read(byteOffset: number) {
while (cursor < byteOffset) {
const byte = bytes[cursor];
cursor += 1;
if (byte === CARRIAGE_RETURN && bytes[cursor] === LINE_FEED && cursor < byteOffset) {
cursor += 1;
} else if (byte !== CARRIAGE_RETURN && byte !== LINE_FEED) {
continue;
}
line += 1;
lineStart = cursor;
lineEnd = -1;
}
if (lineEnd === -1) {
lineEnd = endOfLine(lineStart);
lineText = decoder.decode(bytes.subarray(lineStart, lineEnd));
columnByte = lineStart;
column = 0;
}
// Matches on one line arrive in order, so the column advances from the previous one rather
// than being recounted from the start of the line.
if (byteOffset > columnByte) {
column += decoder.decode(bytes.subarray(columnByte, byteOffset)).length;
columnByte = byteOffset;
}
return { line, column, lineText };
};
}
/** 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<WorkspaceContentMatch[]> {
const located: WorkspaceContentMatch[] = [];
let path = "";
let bytes: Uint8Array = new Uint8Array();
let valid = false;
let read = createLineReader(bytes);
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;
}
read = createLineReader(bytes);
}
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 { line, column, lineText } = read(byteOffset);
const snippetStart = Math.max(0, column - 80);
const snippet = lineText.slice(snippetStart, snippetStart + 240);
located.push({
...match,
line,
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;
}