Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 57ce016293 docs(hub): explain full prompts and credential recovery 2026-09-09 19:58:09 +02:00
73 changed files with 273 additions and 4635 deletions
-8
View File
@@ -152,14 +152,6 @@ 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,13 +50,11 @@ 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-scope").click();
await expect(page.getByTestId("command-center-scope")).toHaveCount(0);
await page.getByTestId("command-center-files-scope").click();
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
const unscoped = await header.boundingBox();
expect(scoped?.height).toBe(unscoped?.height);
-430
View File
@@ -1,430 +0,0 @@
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");
});
});
@@ -1,63 +0,0 @@
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();
});
@@ -1,46 +0,0 @@
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);
}
@@ -1,195 +0,0 @@
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-scope"]',
scope: '[data-testid="command-center-files-scope"]',
fileRow: '[data-testid^="command-center-file-row-"]',
fileIcon: '[data-testid="command-center-file-icon"]',
fileLine: '[data-testid="command-center-file-line"]',
@@ -104,20 +104,19 @@ function stripAssistantMessageId(
return JSON.stringify(envelope);
}
function stripServerFeatures(
function stripCanonicalSubmittedPrompts(
message: string | Buffer,
omittedFeatures: Set<string>,
enabled: boolean,
messageType: unknown,
): string | Buffer {
if (!omittedFeatures.size || messageType !== "status" || typeof message !== "string")
return message;
if (!enabled || 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;
for (const feature of omittedFeatures) delete payload.features[feature];
delete payload.features.canonicalSubmittedPrompts;
return JSON.stringify(envelope);
}
@@ -300,7 +299,7 @@ export async function installDaemonWebSocketGate(page: Page) {
let suppressAgentStream = false;
let forceTimelineEpochReset = false;
let stripAssistantMessageIds = false;
const omittedFeatures = new Set<string>();
let stripCanonicalSubmittedPromptsFeature = false;
let shellToolCommandOverride: string | null = null;
let failingTimelineAgentId: string | null = null;
let holdingTimelineAgentId: string | null = null;
@@ -510,7 +509,11 @@ export async function installDaemonWebSocketGate(page: Page) {
serverMessage?.type,
);
outboundMessage = rewriteShellToolCommand(outboundMessage, shellToolCommandOverride);
outboundMessage = stripServerFeatures(outboundMessage, omittedFeatures, serverMessage?.type);
outboundMessage = stripCanonicalSubmittedPrompts(
outboundMessage,
stripCanonicalSubmittedPromptsFeature,
serverMessage?.type,
);
const isTimelineResponse = serverMessage?.type === "fetch_agent_timeline_response";
if (isTimelineResponse) {
outboundMessage = failTimelineResponse(outboundMessage, failingTimelineAgentId);
@@ -820,12 +823,8 @@ export async function installDaemonWebSocketGate(page: Page) {
setAssistantMessageIdsStripped(stripped: boolean): void {
stripAssistantMessageIds = stripped;
},
setWorkspaceContentSearchStripped(): void {
omittedFeatures.add("workspaceContentSearch");
},
setCanonicalSubmittedPromptsStripped(stripped: boolean): void {
if (stripped) omittedFeatures.add("canonicalSubmittedPrompts");
else omittedFeatures.delete("canonicalSubmittedPrompts");
stripCanonicalSubmittedPromptsFeature = stripped;
},
setShellToolCommandOverride(command: string | null): void {
shellToolCommandOverride = command;
@@ -1,347 +0,0 @@
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,4 +1,3 @@
import { WorkspaceContentSearch } from "./workspace-content-search";
import {
FlatList,
Modal,
@@ -11,8 +10,7 @@ import {
type NativeSyntheticEvent,
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
import { createPortal } from "react-dom";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -38,7 +36,6 @@ 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,
@@ -93,18 +90,7 @@ 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 {
@@ -273,7 +259,7 @@ function useCommandCenterState(): CommandCenterState {
error: fileSearchError,
openFile,
} = useWorkspaceFileSearch({
enabled: open && scope !== "content" && (scope === "files" || Boolean(query.trim())),
enabled: open && (scope === "files" || Boolean(query.trim())),
query,
});
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
@@ -598,7 +584,6 @@ 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);
@@ -715,30 +700,18 @@ export function CommandCenter() {
onScroll: handleListScroll,
scrollEventThrottle: 16,
};
// 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),
const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
[state],
);
const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => dispatchKey(key),
[dispatchKey],
);
const submit = useCallback(() => dispatchKey("Enter"), [dispatchKey]);
const submit = useCallback(() => state.key("Enter"), [state]);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!dispatchKey(event.key)) return false;
if (!state.key(event.key)) return false;
event.preventDefault();
return true;
},
[dispatchKey],
[state],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && state.open && !showBottomSheet,
@@ -757,9 +730,7 @@ export function CommandCenter() {
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={null}
snapPoints={
state.scope === "content" ? CONTENT_SEARCH_SNAP_POINTS : COMMAND_CENTER_SNAP_POINTS
}
snapPoints={COMMAND_CENTER_SNAP_POINTS}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
@@ -773,7 +744,9 @@ export function CommandCenter() {
accessible={false}
>
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
{scopeChipLabel ? <ScopeChip label={scopeChipLabel} onRemove={state.clearScope} /> : null}
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
@@ -782,7 +755,11 @@ export function CommandCenter() {
onChangeText={state.setQuery}
onKeyPress={keyPress}
onSubmitEditing={submit}
placeholder={placeholder}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
@@ -793,89 +770,48 @@ export function CommandCenter() {
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{state.scope === "content" ? (
<WorkspaceContentSearch
query={state.query}
compact={isCompact}
close={state.close}
clearScope={state.clearScope}
keyHandler={contentKeyHandler}
/>
) : (
<>
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</>
)}
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</IsolatedBottomSheetModal>
);
}
if (!state.open) return null;
const desktopContent = (
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
<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>
</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>
<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>
</View>
</Modal>
</OverlayLayerProvider>
);
}
@@ -905,7 +841,7 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
accessibilityLabel={label}
onPress={onRemove}
style={styles.scopeChip}
testID="command-center-scope"
testID="command-center-files-scope"
>
<ThemedFolder size={14} strokeWidth={2.2} />
<Text style={styles.scopeChipLabel}>{label}</Text>
@@ -916,14 +852,12 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
const styles = StyleSheet.create((theme) => ({
overlay: {
...StyleSheet.absoluteFillObject,
flex: 1,
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,
@@ -1,513 +0,0 @@
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,
},
}));
@@ -1,234 +0,0 @@
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();
}
});
@@ -1,206 +0,0 @@
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;
};
}
@@ -1,30 +0,0 @@
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" });
});
});
@@ -1,25 +0,0 @@
/**
* 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 separator = path.lastIndexOf("/");
const normalized = path.replace(/\\/g, "/");
const separator = normalized.lastIndexOf("/");
return {
path,
name: separator >= 0 ? path.slice(separator + 1) : path,
directory: separator >= 0 ? path.slice(0, separator) : "",
path: normalized,
name: separator >= 0 ? normalized.slice(separator + 1) : normalized,
directory: separator >= 0 ? normalized.slice(0, separator) : "",
};
}
@@ -10,11 +10,11 @@ describe("describeWorkspaceFilePath", () => {
});
});
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: "",
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",
});
});
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import { openWorkspaceFileFromExplorer } 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();
openWorkspaceFileInFocusedPane({
location: { path },
openWorkspaceFileFromExplorer({
filePath: path,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
closeExplorerAfterOpen: true,
showMobileAgent: usePanelStore.getState().showMobileAgent,
@@ -141,7 +141,6 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
placement,
}),
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
requestFileNavigation: useWorkspaceLayoutStore.getState().requestFileNavigation,
});
},
[serverId, workspaceId],
@@ -1,4 +1,3 @@
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -328,30 +327,10 @@ 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: allActions,
actions,
});
}
@@ -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 { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform";
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
import {
@@ -119,7 +119,6 @@ export function CompactExplorerSidebarHost({
[openTab],
);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const requestFileNavigation = useWorkspaceLayoutStore((state) => state.requestFileNavigation);
const handleOpenExplorer = useCallback(() => {
if (!model?.workspaceRoot) {
@@ -137,24 +136,16 @@ export function CompactExplorerSidebarHost({
if (!model) {
return;
}
openWorkspaceFileInFocusedPane({
location: { path: filePath },
openWorkspaceFileFromExplorer({
filePath,
persistenceKey: model.persistenceKey,
closeExplorerAfterOpen: presentation === "overlay",
showMobileAgent,
openWorkspaceTabInFocusedPane,
focusWorkspaceTab,
requestFileNavigation,
});
},
[
focusWorkspaceTab,
model,
openWorkspaceTabInFocusedPane,
presentation,
requestFileNavigation,
showMobileAgent,
],
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
);
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
@@ -72,9 +72,3 @@ 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;
const previewPath = input.path.trim();
if (!previewPath) {
return null;
}
@@ -73,7 +73,7 @@ export function resolveFilePreviewReadTarget(input: {
};
}
const workspaceRoot = input.workspaceRoot;
const workspaceRoot = input.workspaceRoot?.trim();
if (!isAbsolutePath(previewPath)) {
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
return null;
@@ -32,20 +32,3 @@ 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,
});
});
+2 -11
View File
@@ -2,21 +2,12 @@ import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { ExplorerFile } from "@/stores/session-store";
export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
// 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;
}
}
const isText = file.kind === "text";
return {
path: file.path,
kind: file.kind,
encoding: isText ? "utf-8" : "none",
content,
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
hasBom: isText && hasUtf8Bom(file.bytes),
mimeType: file.mime,
size: file.size,
+6 -1
View File
@@ -7,7 +7,6 @@ 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 });
@@ -116,6 +115,12 @@ 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,6 +5,7 @@ import {
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { searchKeymap } from "@codemirror/search";
import {
EditorView,
drawSelection,
@@ -41,6 +42,7 @@ export function editorBaseExtensions(onSave: () => void) {
indentWithTab,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
]),
];
}
@@ -61,13 +63,6 @@ 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": {
+16 -36
View File
@@ -1,12 +1,10 @@
import { useTranslation } from "react-i18next";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { FileFind, FileFindModel } from "../find/index.web";
import { useEffect, useRef, useSyncExternalStore } from "react";
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 { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
@@ -40,8 +38,6 @@ 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);
@@ -58,7 +54,6 @@ 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)),
@@ -86,7 +81,7 @@ export function FileEditorView({
view.destroy();
viewRef.current = null;
};
}, [find]);
}, []);
useEffect(() => {
const view = viewRef.current;
@@ -104,12 +99,15 @@ export function FileEditorView({
useEffect(() => {
const view = viewRef.current;
if (!view || !location.lineStart) return;
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
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;
view.dispatch({
selection: { anchor: from, head: to },
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
effects: EditorView.scrollIntoView(from, { y: "center" }),
});
}, [location, navigationRevision]);
}, [location.lineEnd, location.lineStart, navigationRevision]);
useEffect(() => {
viewRef.current?.dispatch({
@@ -142,34 +140,16 @@ 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 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>
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
);
}
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;
@@ -1,117 +0,0 @@
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] },
}));
@@ -1,255 +0,0 @@
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();
}
}
+44 -8
View File
@@ -33,14 +33,13 @@ 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 { WorkspaceFileSource } from "./source";
import { FileSourceView } from "./source/view";
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) => ({
@@ -59,6 +58,24 @@ 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,
@@ -70,13 +87,32 @@ 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 (
<WorkspaceFileSource
<FileSourceView
content={preview.content ?? ""}
filename={filename}
location={location}
navigationRevision={navigationRevision}
size={preview.size}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
@@ -86,7 +122,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>
);
}
@@ -128,7 +164,7 @@ function FilePreviewBody({
);
}
if (preview.kind === "text" || (location.lineStart && preview.content !== undefined)) {
if (preview.kind === "text") {
if (renderKind === "html") {
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
return (
@@ -178,7 +214,7 @@ function FilePreviewBody({
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize(preview.size)}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
}
@@ -203,8 +239,8 @@ export function FilePane({
const supportsEditing = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
);
const normalizedWorkspaceRoot = workspaceRoot;
const normalizedFilePath = location.path || null;
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const readTarget = useMemo(
() =>
normalizedFilePath
@@ -1,39 +0,0 @@
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,6 +1,5 @@
import { useTranslation } from "react-i18next";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View } from "react-native";
import { useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View, type ListRenderItem } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
@@ -59,16 +58,14 @@ 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(source, filename).map((tokens, index) => ({
return highlightCode(content, filename).map((tokens, index) => ({
number: index + 1,
tokens,
}));
return source
return content
.split("\n")
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
}, [content, filename, presentation]);
@@ -80,63 +77,29 @@ 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 (
<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>
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
renderItem={renderSourceLine}
/>
);
}
function SourceLineView({ line, start, end }: { line: SourceLine; start?: number; end?: number }) {
let offset = 0;
function SourceLineView({ line }: { line: SourceLine }) {
return (
<View style={styles.line}>
<Text style={styles.gutter}>{line.number}</Text>
<Text selectable style={styles.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>
);
})}
{line.tokens.map((token) => (
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
{token.text}
</Text>
))}
</Text>
</View>
);
@@ -150,14 +113,9 @@ 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",
+9 -41
View File
@@ -1,10 +1,8 @@
import { useTranslation } from "react-i18next";
import { useEffect, useRef, useState } from "react";
import { FileFind, FileFindModel } from "../find/index.web";
import { useEffect, useRef } from "react";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView, drawSelection } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
import 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";
@@ -61,8 +59,6 @@ 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 });
@@ -75,15 +71,7 @@ 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 }),
@@ -97,7 +85,7 @@ function ReadonlyCodeMirror({
view.destroy();
viewRef.current = null;
};
}, [find]);
}, []);
useEffect(() => {
const view = viewRef.current;
@@ -117,22 +105,12 @@ function ReadonlyCodeMirror({
useEffect(() => {
const view = viewRef.current;
if (!view || !location.lineStart) return;
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]);
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 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>
);
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
}
function languageFor(input: {
@@ -144,16 +122,6 @@ 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,21 +2,6 @@ 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: "تحميل...",
@@ -73,18 +58,6 @@ 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,19 +1,4 @@
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...",
@@ -70,18 +55,6 @@ 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,21 +2,6 @@ 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...",
@@ -73,18 +58,6 @@ 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,21 +2,6 @@ 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...",
@@ -74,18 +59,6 @@ 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,21 +2,6 @@ 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: "読み込み中...",
@@ -73,18 +58,6 @@ 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,21 +2,6 @@ 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: "불러오는 중...",
@@ -73,18 +58,6 @@ 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,21 +2,6 @@ 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...",
@@ -73,18 +58,6 @@ 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,21 +2,6 @@ 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: "Загрузка...",
@@ -73,18 +58,6 @@ 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,21 +2,6 @@ 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: "加载中...",
@@ -73,18 +58,6 @@ 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,7 +46,6 @@ export type KeyboardActionId =
| "settings.toggle"
| "command-center.toggle"
| "command-center.files"
| "command-center.content"
| "shortcuts.dialog.toggle"
| "workspace.terminal.new"
| "workspace.new"
@@ -1196,20 +1196,3 @@ 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,7 +156,6 @@ 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",
@@ -240,7 +239,6 @@ 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",
@@ -1005,7 +1003,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
{
id: "view-toggle-focus-cmd-shift-f-mac",
action: "view.toggle.focus",
combo: "Cmd+Alt+Shift+F",
combo: "Cmd+Shift+F",
when: { mac: true, commandCenter: false },
help: {
id: "toggle-focus",
@@ -1016,7 +1014,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
{
id: "view-toggle-focus-ctrl-shift-f-non-mac",
action: "view.toggle.focus",
combo: "Ctrl+Alt+Shift+F",
combo: "Ctrl+Shift+F",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-focus",
@@ -1025,21 +1023,6 @@ 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",
+1 -5
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" | "content" }
| { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" }
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
const NONE: ShortcutAction = { kind: "none" };
@@ -209,10 +209,6 @@ 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
@@ -1,363 +0,0 @@
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] },
};
});
+1 -6
View File
@@ -75,12 +75,7 @@ export function usePaneContext(): PaneContextValue {
}
export function usePaneFocus(): PaneFocusContextValue {
const value = usePaneFocusIfAny();
const value = useContext(PaneFocusContext);
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,24 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
function createInput(closeExplorerAfterOpen: boolean) {
return {
location: { path: "src/app.tsx" } as WorkspaceFileLocation,
filePath: "src/app.tsx",
persistenceKey: "server:workspace",
closeExplorerAfterOpen,
showMobileAgent: vi.fn(),
openWorkspaceTabInFocusedPane: vi.fn(() => "file-tab"),
focusWorkspaceTab: vi.fn(),
requestFileNavigation: vi.fn(),
};
}
describe("openWorkspaceFileInFocusedPane", () => {
describe("openWorkspaceFileFromExplorer", () => {
it("closes the phone overlay after opening a file", () => {
const input = createInput(true);
openWorkspaceFileInFocusedPane(input);
openWorkspaceFileFromExplorer(input);
expect(input.showMobileAgent).toHaveBeenCalledOnce();
});
@@ -26,29 +24,10 @@ describe("openWorkspaceFileInFocusedPane", () => {
it("keeps the tablet dock open after opening a file", () => {
const input = createInput(false);
openWorkspaceFileInFocusedPane(input);
openWorkspaceFileFromExplorer(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,7 +1,6 @@
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type WorkspaceFileLocation,
} from "@/workspace/file-open";
import {
FOCUSED_PANE_PLACEMENT,
@@ -9,8 +8,8 @@ import {
} from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
interface OpenWorkspaceFileInput {
location: WorkspaceFileLocation;
interface OpenWorkspaceFileFromExplorerInput {
filePath: string;
persistenceKey: string | null;
closeExplorerAfterOpen: boolean;
showMobileAgent: () => void;
@@ -20,24 +19,16 @@ interface OpenWorkspaceFileInput {
placement?: WorkspaceTabPlacement,
) => string | null;
focusWorkspaceTab: (workspaceKey: string, tabId: string) => void;
requestFileNavigation: (tabId: string) => 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 {
export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplorerInput): void {
if (input.closeExplorerAfterOpen) {
input.showMobileAgent();
}
if (!input.persistenceKey) {
return;
}
const location = normalizeWorkspaceFileLocation(input.location);
const location = normalizeWorkspaceFileLocation({ path: input.filePath });
if (!location) {
return;
}
@@ -46,9 +37,7 @@ export function openWorkspaceFileInFocusedPane(input: OpenWorkspaceFileInput): v
createWorkspaceFileTabTarget(location),
FOCUSED_PANE_PLACEMENT,
);
if (!tabId) {
return;
if (tabId) {
input.focusWorkspaceTab(input.persistenceKey, tabId);
}
input.focusWorkspaceTab(input.persistenceKey, tabId);
input.requestFileNavigation(tabId);
}
@@ -1647,13 +1647,17 @@ function WorkspaceScreenContent({
) => openTab({ workspaceKey, target, intent: "reveal", parentTabId, placement }),
[openTab],
);
// 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);
// 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,
}));
}, []);
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" | "content" | null;
export type CommandCenterScope = "files" | null;
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
@@ -110,12 +110,6 @@ 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;
@@ -126,7 +120,6 @@ 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,
@@ -214,9 +207,6 @@ 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"),
@@ -777,7 +767,6 @@ export function createWorkspaceLayoutStore(
pendingAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
fileNavigationRevisionByTabId: {},
explorerSidebarPaneIdByWorkspace: {},
sidePaneIdByWorkspace: {},
openTab: (input) => {
@@ -1051,16 +1040,6 @@ 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);
@@ -1,6 +0,0 @@
/** 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,16 +1,3 @@
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,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
resolveWorkspaceFileSelection,
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
resolveWorkspaceFilePaths,
@@ -8,10 +7,10 @@ import {
} from ".";
describe("normalizeWorkspaceFileLocation", () => {
it("clamps line ranges and leaves the path as the producer gave it", () => {
it("normalizes paths and valid line ranges", () => {
expect(
normalizeWorkspaceFileLocation({
path: "src/app.ts",
path: "src\\app.ts",
lineStart: 12.8,
lineEnd: 20.2,
}),
@@ -35,7 +34,7 @@ describe("normalizeWorkspaceFileLocation", () => {
});
it("rejects empty paths", () => {
expect(normalizeWorkspaceFileLocation({ path: "" })).toBeNull();
expect(normalizeWorkspaceFileLocation({ path: " " })).toBeNull();
});
});
@@ -98,33 +97,13 @@ describe("resolveWorkspaceFilePaths", () => {
});
});
it("normalizes separators in a Windows-shaped path and leaves every other path literal", () => {
it("normalizes Windows separators in the file path", () => {
expect(
resolveWorkspaceFilePaths({
path: "C:\\Users\\me\\repo\\src\\app.ts",
workspaceRoot: "C:\\Users\\me\\repo",
}),
resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "C:/Users/me/repo/src/app.ts",
absolutePath: "/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", () => {
@@ -204,50 +183,3 @@ 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);
});
+5 -49
View File
@@ -1,18 +1,11 @@
import { isAbsolutePath, normalizePathSeparators } from "@/utils/path";
import { isAbsolutePath } 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;
@@ -29,25 +22,16 @@ export function normalizeWorkspaceFileLocation(
return null;
}
// 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;
const path = location.path.trim().replace(/\\/g, "/");
if (!path) {
return null;
}
const lineStart = normalizeLineNumber(location.lineStart);
const lineEnd = normalizeLineNumber(location.lineEnd);
const columnStart = normalizeLineNumber(location.columnStart);
const columnEnd = normalizeLineNumber(location.columnEnd);
return {
path,
...(lineStart ? { lineStart } : {}),
...(lineStart && columnStart ? { columnStart } : {}),
...(lineStart && columnEnd && columnEnd >= (columnStart ?? 1) ? { columnEnd } : {}),
...(location.expectedText !== undefined ? { expectedText: location.expectedText } : {}),
...(lineStart && lineEnd && lineEnd >= lineStart ? { lineEnd } : {}),
};
}
@@ -57,12 +41,7 @@ export function workspaceFileLocationsEqual(
right: WorkspaceFileLocation,
): boolean {
return (
left.path === right.path &&
left.lineStart === right.lineStart &&
left.lineEnd === right.lineEnd &&
left.columnStart === right.columnStart &&
left.columnEnd === right.columnEnd &&
left.expectedText === right.expectedText
left.path === right.path && left.lineStart === right.lineStart && left.lineEnd === right.lineEnd
);
}
@@ -166,8 +145,8 @@ export function resolveWorkspaceFilePaths(input: {
path: string;
workspaceRoot: string;
}): ResolvedWorkspaceFilePaths | null {
const filePath = normalizePathSeparators(input.path);
const workspaceRoot = normalizeAbsolutePath(normalizePathSeparators(input.workspaceRoot));
const filePath = input.path.trim().replace(/\\/g, "/");
const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
if (!filePath || !workspaceRoot) {
return null;
}
@@ -197,26 +176,3 @@ 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,4 +1,3 @@
import type { WorkspaceContentSearchResult } from "@getpaseo/protocol/messages";
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import {
@@ -4492,33 +4491,6 @@ 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,4 +1,3 @@
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,57 +2688,6 @@ 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(),
@@ -3262,8 +3211,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
WorkspaceClearAttentionRequestSchema,
WorkspaceMarkUnreadRequestSchema,
FileExplorerRequestSchema,
WorkspaceContentSearchRequestSchema,
WorkspaceContentCancelRequestSchema,
FileSubscribeRequestSchema,
FileUnsubscribeRequestSchema,
FileWriteRequestSchema,
@@ -3569,7 +3516,6 @@ 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.
@@ -6671,8 +6617,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeArchiveResponseSchema,
CreatePaseoWorktreeResponseSchema,
FileExplorerResponseSchema,
WorkspaceContentSearchResponseSchema,
WorkspaceContentCancelResponseSchema,
FileSubscribeResponseSchema,
FileUnsubscribeResponseSchema,
FileWriteResponseSchema,
@@ -81,8 +81,6 @@ 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",
@@ -294,8 +292,6 @@ 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",
@@ -1,42 +0,0 @@
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,44 +592,3 @@ 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, toWorkspaceRelativePath } from "../path-utils.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import { runGitCommand } from "../../utils/run-git-command.js";
export type ExplorerEntryKind = "file" | "directory";
@@ -15,7 +15,6 @@ export interface ListDirectoryParams {
}
export interface ReadFileParams {
maxBytes?: number;
root: string;
relativePath: string;
}
@@ -194,9 +193,8 @@ export async function listDirectoryEntries({
export async function readExplorerFile({
root,
relativePath,
maxBytes,
}: ReadFileParams): Promise<FileExplorerFile> {
const file = await readExplorerFileBytes({ root, relativePath, maxBytes });
const file = await readExplorerFileBytes({ root, relativePath });
if (file.kind === "image") {
return {
@@ -238,7 +236,6 @@ 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);
@@ -250,8 +247,6 @@ 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 }),
@@ -260,10 +255,7 @@ export async function readExplorerFileBytes({
revision: fileRevision(stats),
};
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);
const buffer = await handle.readFile();
if (ext in IMAGE_MIME_TYPES) {
return {
...basePayload,
@@ -297,7 +289,7 @@ export async function readExplorerFileBytes({
}
export async function streamExplorerFile(
{ root, relativePath, maxBytes }: ReadFileParams,
{ root, relativePath }: ReadFileParams,
consume: (file: FileExplorerFileStream) => Promise<void>,
): Promise<void> {
const filePath = await resolveScopedPath({ root, relativePath });
@@ -309,8 +301,6 @@ 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();
@@ -871,7 +861,7 @@ function normalizeRelativePath({ root, targetPath }: { root: string; targetPath:
const normalizedRoot = expandUserPath(root);
const normalizedTarget = expandUserPath(targetPath);
const relative = path.relative(normalizedRoot, normalizedTarget);
return relative === "" ? "." : toWorkspaceRelativePath(relative);
return relative === "" ? "." : relative.split(path.sep).join("/");
}
function textMimeTypeForExtension(ext: string): string {
@@ -1,18 +0,0 @@
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");
});
+9 -16
View File
@@ -1,5 +1,5 @@
import { homedir } from "node:os";
import { isAbsolute, posix, resolve, sep, win32 } from "node:path";
import { isAbsolute, posix, resolve, win32 } from "node:path";
export function assertAbsolutePath(cwd: string): void {
if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) {
@@ -12,17 +12,19 @@ function hasHomePrefix(value: string): boolean {
}
export function expandUserPath(value: string): string {
if (hasHomePrefix(value)) {
return resolve(homedir(), value.slice(2));
const trimmed = value.trim();
if (hasHomePrefix(trimmed)) {
return resolve(homedir(), trimmed.slice(2));
}
return resolve(value);
return resolve(trimmed);
}
export function resolvePathFromBase(baseCwd: string, requestedPath: string): string {
if (hasHomePrefix(requestedPath) || isAbsolute(requestedPath)) {
return expandUserPath(requestedPath);
const trimmed = requestedPath.trim();
if (hasHomePrefix(trimmed) || isAbsolute(trimmed)) {
return expandUserPath(trimmed);
}
return resolve(baseCwd, requestedPath);
return resolve(baseCwd, trimmed);
}
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
@@ -38,12 +40,3 @@ 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,11 +2624,6 @@ 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,4 +1,3 @@
import { searchWorkspaceContent } from "../../workspace-content-search/index.js";
import type pino from "pino";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import {
@@ -64,7 +63,6 @@ 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;
@@ -80,50 +78,6 @@ 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 {
@@ -260,14 +214,13 @@ 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;
const cwd = workspaceCwd.trim();
if (!cwd) {
this.host.emit(
{
@@ -317,45 +270,41 @@ export class WorkspaceFilesSession {
}
}
if (request.acceptBinary && this.host.hasBinaryChannel()) {
await streamExplorerFile(
{ root: cwd, relativePath: requestedPath, maxBytes: request.maxBytes },
async (file) => {
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 this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
opcode: FileTransferOpcode.FileChunk,
requestId,
metadata: {
mime: file.mimeType,
size: file.size,
encoding: file.encoding,
modifiedAt: file.modifiedAt,
revision: file.revision,
},
payload: chunk,
}),
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,
);
},
);
}
await this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId,
}),
source,
);
});
} else {
const file = await readExplorerFile({
maxBytes: request.maxBytes,
root: cwd,
relativePath: requestedPath,
});
@@ -442,7 +391,7 @@ export class WorkspaceFilesSession {
async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise<void> {
const { cwd: workspaceCwd, path: requestedPath, requestId } = request;
const cwd = workspaceCwd;
const cwd = workspaceCwd.trim();
if (!cwd) {
this.host.emit({
type: "file_download_token_response",
@@ -1712,7 +1712,6 @@ 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.
@@ -1,227 +0,0 @@
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",
});
});
@@ -1,345 +0,0 @@
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;
}
+3 -1
View File
@@ -169,7 +169,7 @@ The workflow keeps one classifier branch and one worker branch. It does not dupl
## Prompt and context
Prompt blocks remain ordered. Includes are literal file contents; Hub does not recursively scan partial text.
Prompt blocks remain ordered. Includes are literal file contents; Hub does not recursively scan partial text. Hub sends the rendered prompt separately from the agent title, so the 200-character title limit does not limit your instructions or context.
```yaml
prompt:
@@ -246,4 +246,6 @@ allow_outputs:
The workflow `max_runtime` limits the complete run. Every step has its own `max_runtime` and `idle_timeout`; remaining workflow time caps both. A timeout fails the run and stops later steps.
Hub reconnects to the existing agent after a connection loss. If a run used temporary environment credentials and Hub no longer owns their live lease, recovery fails with `execution_credentials_unavailable`. This includes credentials revoked during Hub shutdown. Start a new run to obtain fresh credentials; an existing agent cannot receive a replacement process environment.
The [configuration reference](/docs/hub/configuration/hub-yml) lists every field. Provider filters and invocation text are in [Triggers](/docs/hub/triggers).