mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 00:32:45 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c581c2486 | ||
|
|
4e9dbc7f54 | ||
|
|
c33c1d0855 | ||
|
|
770648327f | ||
|
|
2a2362f5cf | ||
|
|
6e57bb1e4f | ||
|
|
6db8bd124f | ||
|
|
eb1fe10375 | ||
|
|
db8c22e135 | ||
|
|
0c38afa76f | ||
|
|
7b813f7f58 | ||
|
|
d014cfdb2f | ||
|
|
ec5ab3fc4d | ||
|
|
69510ac72f |
@@ -152,6 +152,14 @@ jobs:
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Install ripgrep (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
|
||||
- name: Install ripgrep (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: choco install ripgrep --yes --no-progress
|
||||
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
|
||||
@@ -50,11 +50,13 @@ RUN set -eux; \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
ripgrep \
|
||||
gosu \
|
||||
lbzip2 \
|
||||
openssh-client \
|
||||
procps \
|
||||
tini; \
|
||||
rg --version; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||
|
||||
@@ -102,8 +102,8 @@ test("dropping the files scope leaves the search row the same height", async ({
|
||||
const header = panel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
await page.getByTestId("command-center-scope").click();
|
||||
await expect(page.getByTestId("command-center-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
|
||||
function source(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
function query(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Find in pane", exact: true });
|
||||
}
|
||||
function status(page: Page) {
|
||||
return page.getByRole("status", { name: "Find matches" });
|
||||
}
|
||||
async function openSource(page: Page, filename: string) {
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, filename);
|
||||
await expect(source(page)).toBeVisible();
|
||||
}
|
||||
async function findInSource(page: Page, text: string) {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill(text);
|
||||
}
|
||||
async function expectQuerySelected(page: Page, text: string) {
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue(text);
|
||||
await expect
|
||||
.poll(() =>
|
||||
query(page).evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd]),
|
||||
)
|
||||
.toEqual([0, text.length]);
|
||||
}
|
||||
async function closeFind(page: Page) {
|
||||
await query(page).press("Escape");
|
||||
await expect(source(page)).toBeFocused();
|
||||
}
|
||||
async function expectActiveMatchUncovered(page: Page) {
|
||||
const match = page.locator(".cm-searchMatch-selected").filter({ visible: true }).first();
|
||||
await expect(match).toBeVisible();
|
||||
const widget = page.getByLabel("Find", { exact: true }).filter({ visible: true });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const m = await match.boundingBox();
|
||||
const w = await widget.boundingBox();
|
||||
if (!m || !w) return "missing";
|
||||
const covered =
|
||||
m.x < w.x + w.width && m.x + m.width > w.x && m.y < w.y + w.height && m.y + m.height > w.y;
|
||||
return covered ? "covered by Find" : "visible";
|
||||
})
|
||||
.toBe("visible");
|
||||
}
|
||||
|
||||
async function findBoxes(page: Page) {
|
||||
const pane = (await page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.boundingBox())!;
|
||||
const widget = (await page
|
||||
.getByLabel("Find", { exact: true })
|
||||
.filter({ visible: true })
|
||||
.boundingBox())!;
|
||||
return { pane, widget };
|
||||
}
|
||||
|
||||
async function expectFindInsidePane(page: Page) {
|
||||
const { pane, widget } = await findBoxes(page);
|
||||
expect(widget.x).toBeGreaterThanOrEqual(pane.x);
|
||||
expect(widget.x + widget.width).toBeLessThanOrEqual(pane.x + pane.width);
|
||||
expect(widget.y).toBeGreaterThanOrEqual(pane.y);
|
||||
expect(widget.y + widget.height).toBeLessThanOrEqual(pane.y + pane.height + 1);
|
||||
}
|
||||
|
||||
async function expectFindAtTopRight(page: Page) {
|
||||
await expectFindInsidePane(page);
|
||||
const { pane, widget } = await findBoxes(page);
|
||||
expect(widget.y).toBeLessThan(pane.y + 70);
|
||||
}
|
||||
|
||||
test("finds literal text at the top of editable source and returns focus at the inspected match", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-" });
|
||||
const file = path.join(workspace.repoPath, "source.txt");
|
||||
await writeFile(file, "first a.b\nsecond axb\nthird a.b\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "source.txt");
|
||||
await findInSource(page, "a.b");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await query(page).fill("A.B");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await query(page).fill("a.b");
|
||||
await expectFindAtTopRight(page);
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await query(page).press("Shift+Enter");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await page.screenshot({ path: testInfo.outputPath("editable-find.png") });
|
||||
await closeFind(page);
|
||||
await expect(query(page)).toBeHidden();
|
||||
await expect(page.getByLabel("Line 1, column 10")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
|
||||
|
||||
await test.step("reopen with the shortcut, preserve replace and Undo/save", async () => {
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("literal");
|
||||
await page.getByRole("button", { name: "Replace", exact: true }).click();
|
||||
await expect(source(page)).toContainText("first literal");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await page.screenshot({ path: testInfo.outputPath("replace.png") });
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+z");
|
||||
await expect(source(page)).toContainText("first a.b");
|
||||
await source(page).press("ControlOrMeta+s");
|
||||
await expect.poll(() => readFile(file, "utf8")).toBe("first a.b\nsecond axb\nthird a.b\n");
|
||||
await findInSource(page, "a.b");
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("all");
|
||||
await page.getByRole("button", { name: "Replace all", exact: true }).click();
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+s");
|
||||
await expect.poll(() => readFile(file, "utf8")).toBe("first all\nsecond axb\nthird all\n");
|
||||
});
|
||||
});
|
||||
|
||||
test("searches the unsaved buffer while disconnected", async ({ page, withWorkspace }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-draft-" });
|
||||
const file = path.join(workspace.repoPath, "draft.txt");
|
||||
await writeFile(file, "saved on disk\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "draft.txt");
|
||||
await gate.drop();
|
||||
await source(page).fill("unsaved needle\nsecond needle\n");
|
||||
await findInSource(page, "needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
expect(await readFile(file, "utf8")).toBe("saved on disk\n");
|
||||
gate.restore();
|
||||
});
|
||||
|
||||
test("searches read-only source beyond the viewport without replace controls", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-readonly-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "large.txt"),
|
||||
"needle first\n" + "plain source line\n".repeat(70_000) + "needle last\n",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "large.txt");
|
||||
await findInSource(page, "needle");
|
||||
await expect(source(page)).toHaveAttribute("contenteditable", "false");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expect(source(page)).toContainText("needle last");
|
||||
await page.screenshot({ path: testInfo.outputPath("readonly-find.png") });
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("plain");
|
||||
await expect(status(page)).toHaveText("1 of 10000+");
|
||||
});
|
||||
|
||||
test("targets the focused source in split panes and refocuses an open query", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-split-" });
|
||||
await writeFile(path.join(workspace.repoPath, "left.txt"), "left needle\n");
|
||||
await writeFile(path.join(workspace.repoPath, "right.txt"), "right needle\nright needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "left.txt");
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await openFileFromExplorer(page, "right.txt");
|
||||
const left = source(page).filter({ hasText: "left needle" });
|
||||
const right = source(page).filter({ hasText: "right needle" });
|
||||
await expect(left).toBeVisible();
|
||||
await expect(right).toBeVisible();
|
||||
await left.click();
|
||||
await left.press("ControlOrMeta+f");
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await query(page).press("Escape");
|
||||
await right.click();
|
||||
await right.press("ControlOrMeta+f");
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await right.click();
|
||||
await right.press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
|
||||
});
|
||||
|
||||
test.describe("narrow touch browser", () => {
|
||||
test.use({ hasTouch: true });
|
||||
test("keeps Find controls inside a narrow source pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-touch-" });
|
||||
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "touch.txt");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find-closed.png") });
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectFindInsidePane(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
async function selectFirstTwoLines(page: Page) {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+Home");
|
||||
await source(page).press("Shift+ArrowDown");
|
||||
await source(page).press("Shift+End");
|
||||
}
|
||||
|
||||
async function selectLastWord(page: Page) {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+End");
|
||||
await source(page).press("ArrowUp");
|
||||
await source(page).press("Home");
|
||||
await source(page).press("Shift+End");
|
||||
}
|
||||
|
||||
test("declines multiline selection seeds and keeps replacement aligned with the visible query", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-single-line-" });
|
||||
const file = path.join(workspace.repoPath, "selection.txt");
|
||||
await writeFile(file, "alpha\nbeta\nalphabeta\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "selection.txt");
|
||||
|
||||
await selectFirstTwoLines(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
|
||||
await query(page).press("Enter");
|
||||
await expect(query(page)).toHaveValue("");
|
||||
await closeFind(page);
|
||||
|
||||
await selectFirstTwoLines(page);
|
||||
await source(page).press("ControlOrMeta+g");
|
||||
await expect(query(page)).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
|
||||
await closeFind(page);
|
||||
|
||||
await selectLastWord(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toHaveValue("alphabeta");
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await closeFind(page);
|
||||
await expect(page.getByLabel("Line 3, column 10")).toBeVisible();
|
||||
|
||||
await selectFirstTwoLines(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toHaveValue("alphabeta");
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("replaced");
|
||||
await page.getByRole("button", { name: "Replace", exact: true }).click();
|
||||
await expect(source(page)).toContainText("alpha");
|
||||
await expect(source(page)).toContainText("beta");
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
await page.screenshot({ path: testInfo.outputPath("single-line-query-replacement.png") });
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+s");
|
||||
await expect.poll(() => readFile(file, "utf8")).toBe("alpha\nbeta\nreplaced\n");
|
||||
});
|
||||
|
||||
for (const startingField of ["Find in pane", "Replace with"] as const) {
|
||||
test(`repeated Find from ${startingField} selects the query for new typing`, async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-repeat-input-" });
|
||||
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "source.txt");
|
||||
await findInSource(page, "needle");
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
const replacement = page.getByRole("textbox", { name: "Replace with" });
|
||||
await replacement.fill("replacement");
|
||||
|
||||
await test.step("repeat Find from the input and type a fresh query", async () => {
|
||||
const startingInput = page.getByRole("textbox", { name: startingField, exact: true });
|
||||
await startingInput.press("End");
|
||||
await startingInput.press("ControlOrMeta+f");
|
||||
await expectQuerySelected(page, "needle");
|
||||
await page.keyboard.type("first");
|
||||
await expect(query(page)).toHaveValue("first");
|
||||
await expect(replacement).toHaveValue("replacement");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
});
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath("repeat-find-input.png") });
|
||||
await closeFind(page);
|
||||
await expect(page.getByLabel("Line 1, column 13")).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("the active match stays visible under the floating widget", () => {
|
||||
test.describe("narrow touch browser", () => {
|
||||
test.use({ hasTouch: true });
|
||||
test("reveals a short file's match that the widget would cover", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
|
||||
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "touch.txt");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-cover-split-" });
|
||||
await writeFile(path.join(workspace.repoPath, "left.txt"), "left filler\n");
|
||||
await writeFile(path.join(workspace.repoPath, "right.txt"), "needle one\nplain\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "left.txt");
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await openFileFromExplorer(page, "right.txt");
|
||||
const right = source(page).filter({ hasText: "needle one" });
|
||||
await expect(right).toBeVisible();
|
||||
await right.click();
|
||||
await right.press("ControlOrMeta+f");
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("split-match-uncovered.png") });
|
||||
|
||||
await test.step("stays visible when Replace expands the widget", async () => {
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("hay");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("split-replace-uncovered.png") });
|
||||
});
|
||||
|
||||
await test.step("navigation and query focus still work", async () => {
|
||||
await query(page).press("ControlOrMeta+f");
|
||||
await expectQuerySelected(page, "needle");
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await expectActiveMatchUncovered(page);
|
||||
});
|
||||
await query(page).press("Escape");
|
||||
await expect(right).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("Go to line keeps its dialog with Find closed and open", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-goto-" });
|
||||
const lines = Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join("\n");
|
||||
await writeFile(path.join(workspace.repoPath, "lines.txt"), `${lines}\n`);
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "lines.txt");
|
||||
const gotoInput = page.getByRole("textbox", { name: "Go to line" });
|
||||
|
||||
await test.step("with Find closed", async () => {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+Alt+g");
|
||||
await expect(gotoInput).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("goto-line-find-closed.png") });
|
||||
await gotoInput.fill("12");
|
||||
await gotoInput.press("Enter");
|
||||
await expect(page.getByLabel("Line 12, column 1")).toBeVisible();
|
||||
await expect(gotoInput).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("with Find open", async () => {
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("line 3");
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+Alt+g");
|
||||
await expect(gotoInput).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("goto-line-find-open.png") });
|
||||
await gotoInput.fill("30");
|
||||
await gotoInput.press("Enter");
|
||||
await expect(page.getByLabel("Line 30, column 1")).toBeVisible();
|
||||
await expect(query(page)).toHaveValue("line 3");
|
||||
await expect(status(page)).toHaveText("11 matches");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdtemp, rm, symlink } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test as base, expect } from "../support/fixtures";
|
||||
import { prepareContentSearch } from "../support/helpers/workspace-content-search";
|
||||
|
||||
// The missing-rg fixture builds a PATH from `which` and POSIX symlinks. Windows needs its own
|
||||
// PATH construction, which belongs to the CI gate rather than to this journey.
|
||||
base.skip(process.platform === "win32", "the missing-ripgrep PATH fixture is POSIX-shaped");
|
||||
|
||||
const test = base.extend<{}, { searchHostPath: string }>({
|
||||
searchHostPath: [
|
||||
async ({ browserName }, provide) => {
|
||||
void browserName;
|
||||
const bin = await mkdtemp(path.join(os.tmpdir(), "search-host-path-"));
|
||||
await symlink(
|
||||
execFileSync("which", ["git"], { encoding: "utf8" }).trim(),
|
||||
path.join(bin, "git"),
|
||||
);
|
||||
await symlink(process.execPath, path.join(bin, "node"));
|
||||
try {
|
||||
await provide(bin);
|
||||
} finally {
|
||||
await rm(bin, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
e2eDaemonEnvironment: [
|
||||
async ({ searchHostPath }, provide) => {
|
||||
await provide({ PATH: searchHostPath });
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
||||
test("missing host ripgrep is actionable and Retry works after installation", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
searchHostPath,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await page.keyboard.press("Control+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("needle");
|
||||
await expect(
|
||||
page.getByText("Install ripgrep on this host and make rg available on its PATH, then retry", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByTestId("command-center-panel")
|
||||
.screenshot({ path: testInfo.outputPath("missing-rg.png") });
|
||||
await symlink(
|
||||
execFileSync("which", ["rg"], { encoding: "utf8" }).trim(),
|
||||
path.join(searchHostPath, "rg"),
|
||||
);
|
||||
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { writeFile, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
import { waitForTerminalContent } from "../support/helpers/terminal-perf";
|
||||
|
||||
test("content-search entry and dismissal preserve live shell input and terminal focus", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const harness = await TerminalE2EHarness.create({ tempPrefix: "content-search-terminal-" });
|
||||
try {
|
||||
const terminal = await harness.createTerminal({ name: "Search shortcut QA" });
|
||||
await writeFile(
|
||||
path.join(harness.tempRepo.path, "source.ts"),
|
||||
'export const greeting = "needle";\n',
|
||||
);
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await harness.setupPrompt(page);
|
||||
await searchFromPendingShellCommand(page, harness, testInfo.outputPath("terminal-search.png"));
|
||||
expect(await readFile(path.join(harness.tempRepo.path, "shell-input.txt"), "utf8")).toBe(
|
||||
"untouched",
|
||||
);
|
||||
} finally {
|
||||
await harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function searchFromPendingShellCommand(
|
||||
page: import("@playwright/test").Page,
|
||||
harness: TerminalE2EHarness,
|
||||
screenshot: string,
|
||||
) {
|
||||
const terminal = harness.terminalSurface(page);
|
||||
await terminal.pressSequentially("printf untouched > shell-input.txt", { delay: 0 });
|
||||
await page.keyboard.press("Control+Shift+F");
|
||||
const query = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await expect(query).toBeFocused();
|
||||
await query.pressSequentially("needle", { delay: 0 });
|
||||
await expect(page.getByRole("button", { name: /source.ts:1:26/ })).toBeVisible();
|
||||
await page.screenshot({ path: screenshot });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toBeHidden();
|
||||
await page.keyboard.press("Enter");
|
||||
await terminal.pressSequentially("printf '\\nSEARCH_SHELL_RESUMED\\n'\n", { delay: 0 });
|
||||
await waitForTerminalContent(page, (text) => text.includes("SEARCH_SHELL_RESUMED"), 10_000);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
prepareContentSearch,
|
||||
searchContents,
|
||||
previewAndOpenSecondOccurrence,
|
||||
openCompactPreview,
|
||||
preserveDirtyBufferAndUndo,
|
||||
recoverChangedAndMissingPreview,
|
||||
searchContentsByTouch,
|
||||
} from "../support/helpers/workspace-content-search";
|
||||
|
||||
test.use({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
|
||||
});
|
||||
test("searches unopened saved contents, previews keyboard selection and opens the exact occurrence", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await searchContents(page, "needle");
|
||||
await previewAndOpenSecondOccurrence(page, testInfo.outputPath("content-search-wide.png"));
|
||||
await preserveDirtyBufferAndUndo(page, workspace);
|
||||
});
|
||||
test("compact search keeps list, preview and Open in the existing popup", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await searchContentsByTouch(page, "needle");
|
||||
await openCompactPreview(page, testInfo.outputPath("content-search-compact.png"));
|
||||
});
|
||||
|
||||
test("changed saved files, empty results and occurrence limits stay explicit", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await searchContents(page, "needle");
|
||||
await recoverChangedAndMissingPreview(
|
||||
page,
|
||||
workspace,
|
||||
testInfo.outputPath("content-search-limited.png"),
|
||||
);
|
||||
});
|
||||
|
||||
test("a host without content search advertises an update requirement without sending the new RPC", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
gate.setWorkspaceContentSearchStripped();
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await expect(
|
||||
page.getByText("Update this host to search file contents", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(
|
||||
page.getByText("Update this host to search file contents", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(gate.getClientRequestCount("fs.content.search.request")).toBe(0);
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(
|
||||
page.getByRole("textbox", {
|
||||
name: "Search commands, files, workspaces, and agents...",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Meta+p");
|
||||
await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
import { openLiteralSourceOccurrence } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("SVG image opening stays visual while a saved occurrence opens exact source", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await openLiteralSourceOccurrence(
|
||||
page,
|
||||
workspace,
|
||||
{
|
||||
path: "icon.svg",
|
||||
content: '<svg xmlns="http://www.w3.org/2000/svg"><title>needle</title></svg>',
|
||||
result: 'icon.svg:1:48 <svg xmlns="http://www.w3.org/2000/svg"><title>needle</title></svg>',
|
||||
imageFirst: true,
|
||||
},
|
||||
testInfo.outputPath("svg-source-open.png"),
|
||||
);
|
||||
});
|
||||
|
||||
test("literal leading-space file identity survives preview and exact tab activation", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await openLiteralSourceOccurrence(
|
||||
page,
|
||||
workspace,
|
||||
{
|
||||
path: " leading.txt",
|
||||
content: "needle",
|
||||
result: " leading.txt:1:1 needle",
|
||||
cursor: "Line 1, column 7",
|
||||
},
|
||||
testInfo.outputPath("literal-path-open.png"),
|
||||
);
|
||||
});
|
||||
|
||||
test("CR-only saved occurrences use the source editor line and exact selection", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await openLiteralSourceOccurrence(
|
||||
page,
|
||||
workspace,
|
||||
{
|
||||
path: "cr-only.txt",
|
||||
content: "first\rneedle",
|
||||
result: "cr-only.txt:2:1 needle",
|
||||
cursor: "Line 2, column 7",
|
||||
},
|
||||
testInfo.outputPath("cr-source-open.png"),
|
||||
);
|
||||
});
|
||||
|
||||
import {
|
||||
openLiteralBackslashFile,
|
||||
reportSkippedLargeFiles,
|
||||
} from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("an empty result says which files the host never opened", async ({ page, withWorkspace }) => {
|
||||
await reportSkippedLargeFiles(page, await withWorkspace());
|
||||
});
|
||||
|
||||
// "a\b.txt" is one file on POSIX and a nested path on Windows, so only this fixture is unavailable
|
||||
// there. The skip belongs to this test: at file scope it annotates the whole suite and takes every
|
||||
// other journey in this file with it. Windows separator handling is covered by
|
||||
// resolveWorkspaceFilePaths and toWorkspaceRelativePath.
|
||||
test("a file name containing a backslash opens itself, not a same-named nested path", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.skip(
|
||||
process.platform === "win32",
|
||||
"a file name containing a backslash is not a valid Windows fixture",
|
||||
);
|
||||
await openLiteralBackslashFile(page, await withWorkspace());
|
||||
});
|
||||
|
||||
import { reopenSameOccurrenceAfterMovingAway } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("opening the same occurrence again navigates the pane it is already showing", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await reopenSameOccurrenceAfterMovingAway(page, await withWorkspace());
|
||||
});
|
||||
|
||||
import { revealExactPathOnHover } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("a result's tooltip paints the exact path, however deep", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const { rows, tip } = await revealExactPathOnHover(page, await withWorkspace());
|
||||
await rows.first().hover();
|
||||
await expect(tip).toBeVisible();
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: testInfo.outputPath("tooltip-exact-path.png") });
|
||||
// Pointing must not move the keyboard selection: Enter still opens the first result.
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId("command-center-panel")).toBeHidden();
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("SHARED_UTIL");
|
||||
});
|
||||
|
||||
import { keepDesktopOverlayChrome } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("the desktop panel keeps its backdrop, Escape and focus return in every scope", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await keepDesktopOverlayChrome(page, await withWorkspace());
|
||||
});
|
||||
@@ -26,7 +26,7 @@ const SURFACE_SELECTORS = {
|
||||
input: '[data-testid="command-center-input"]',
|
||||
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
|
||||
results: '[data-testid="command-center-results"]',
|
||||
scope: '[data-testid="command-center-files-scope"]',
|
||||
scope: '[data-testid="command-center-scope"]',
|
||||
fileRow: '[data-testid^="command-center-file-row-"]',
|
||||
fileIcon: '[data-testid="command-center-file-icon"]',
|
||||
fileLine: '[data-testid="command-center-file-line"]',
|
||||
|
||||
@@ -104,19 +104,20 @@ function stripAssistantMessageId(
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function stripCanonicalSubmittedPrompts(
|
||||
function stripServerFeatures(
|
||||
message: string | Buffer,
|
||||
enabled: boolean,
|
||||
omittedFeatures: Set<string>,
|
||||
messageType: unknown,
|
||||
): string | Buffer {
|
||||
if (!enabled || messageType !== "status" || typeof message !== "string") return message;
|
||||
if (!omittedFeatures.size || messageType !== "status" || typeof message !== "string")
|
||||
return message;
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { payload?: { status?: unknown; features?: Record<string, unknown> } };
|
||||
payload?: { status?: unknown; features?: Record<string, unknown> };
|
||||
};
|
||||
const payload = envelope.message?.payload ?? envelope.payload;
|
||||
if (payload?.status !== "server_info" || !payload.features) return message;
|
||||
delete payload.features.canonicalSubmittedPrompts;
|
||||
for (const feature of omittedFeatures) delete payload.features[feature];
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
@@ -299,7 +300,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
let suppressAgentStream = false;
|
||||
let forceTimelineEpochReset = false;
|
||||
let stripAssistantMessageIds = false;
|
||||
let stripCanonicalSubmittedPromptsFeature = false;
|
||||
const omittedFeatures = new Set<string>();
|
||||
let shellToolCommandOverride: string | null = null;
|
||||
let failingTimelineAgentId: string | null = null;
|
||||
let holdingTimelineAgentId: string | null = null;
|
||||
@@ -509,11 +510,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
serverMessage?.type,
|
||||
);
|
||||
outboundMessage = rewriteShellToolCommand(outboundMessage, shellToolCommandOverride);
|
||||
outboundMessage = stripCanonicalSubmittedPrompts(
|
||||
outboundMessage,
|
||||
stripCanonicalSubmittedPromptsFeature,
|
||||
serverMessage?.type,
|
||||
);
|
||||
outboundMessage = stripServerFeatures(outboundMessage, omittedFeatures, serverMessage?.type);
|
||||
const isTimelineResponse = serverMessage?.type === "fetch_agent_timeline_response";
|
||||
if (isTimelineResponse) {
|
||||
outboundMessage = failTimelineResponse(outboundMessage, failingTimelineAgentId);
|
||||
@@ -823,8 +820,12 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
setAssistantMessageIdsStripped(stripped: boolean): void {
|
||||
stripAssistantMessageIds = stripped;
|
||||
},
|
||||
setWorkspaceContentSearchStripped(): void {
|
||||
omittedFeatures.add("workspaceContentSearch");
|
||||
},
|
||||
setCanonicalSubmittedPromptsStripped(stripped: boolean): void {
|
||||
stripCanonicalSubmittedPromptsFeature = stripped;
|
||||
if (stripped) omittedFeatures.add("canonicalSubmittedPrompts");
|
||||
else omittedFeatures.delete("canonicalSubmittedPrompts");
|
||||
},
|
||||
setShellToolCommandOverride(command: string | null): void {
|
||||
shellToolCommandOverride = command;
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { composerLocator } from "./composer";
|
||||
import type { CreatedWorkspace } from "./with-workspace";
|
||||
|
||||
export async function prepareContentSearch(workspace: CreatedWorkspace) {
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-a.ts"),
|
||||
'export const greeting = "NEEDLE";\n',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-b.ts"),
|
||||
'const emoji = "é🙂 needle needle";\n',
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
}
|
||||
export async function searchContents(page: Page, query: string) {
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(query);
|
||||
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
|
||||
}
|
||||
export async function previewAndOpenSecondOccurrence(page: Page, screenshot: string) {
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("export const greeting");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("const emoji");
|
||||
await expect(input).toBeFocused();
|
||||
// The query field is the Command Center's own header input, above the results and preview.
|
||||
const inputBox = await input.boundingBox();
|
||||
const previewBox = await panel.getByTestId("file-source-editor").boundingBox();
|
||||
expect(inputBox!.y + inputBox!.height).toBeLessThan(previewBox!.y);
|
||||
await panel.screenshot({ path: screenshot });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("const emoji");
|
||||
// The source view uses a real CodeMirror document; select-all is unnecessary and would hide a bad jump.
|
||||
await expect(page.getByLabel("Line 1, column 26", { exact: true })).toBeVisible();
|
||||
}
|
||||
export async function openCompactPreview(page: Page, screenshot: string) {
|
||||
await page.getByRole("button", { name: /search-a.ts:1:26/ }).click();
|
||||
await expect(page.getByRole("button", { name: "Back to results", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("command-center-panel").getByTestId("file-source-editor"),
|
||||
).toContainText("export const greeting");
|
||||
await page.getByTestId("command-center-panel").screenshot({ path: screenshot });
|
||||
await page.getByRole("button", { name: "Open", exact: true }).click();
|
||||
await expect(page.getByTestId("command-center-panel")).toBeHidden();
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("export const greeting");
|
||||
}
|
||||
|
||||
export async function preserveDirtyBufferAndUndo(page: Page, workspace: CreatedWorkspace) {
|
||||
const source = page.getByTestId("file-source-editor").locator(".cm-content");
|
||||
await source.fill("const localUnsaved = 42;\n");
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-b.ts"),
|
||||
'const emoji = "é🙂 needle needle";\n// changed on disk\n',
|
||||
);
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await searchContents(page, "needle");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(
|
||||
page.getByTestId("command-center-panel").getByTestId("file-source-editor"),
|
||||
).toContainText("changed on disk");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(source).toContainText("localUnsaved");
|
||||
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toContainText(
|
||||
"saved match is no longer at this location",
|
||||
);
|
||||
await source.press("Control+z");
|
||||
await expect(source).toContainText('const emoji = "é🙂 needle needle";');
|
||||
await expect(source).not.toContainText("localUnsaved");
|
||||
}
|
||||
|
||||
export async function recoverChangedAndMissingPreview(
|
||||
page: Page,
|
||||
workspace: CreatedWorkspace,
|
||||
screenshot: string,
|
||||
) {
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await writeFile(path.join(workspace.repoPath, "search-b.ts"), "changed after search\n");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(panel.getByRole("status")).toContainText(
|
||||
"saved match is no longer at this location",
|
||||
);
|
||||
await unlink(path.join(workspace.repoPath, "search-a.ts"));
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-a.ts"),
|
||||
'export const greeting = "NEEDLE";\n',
|
||||
);
|
||||
await panel.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("NEEDLE");
|
||||
await input.fill("not-present-anywhere");
|
||||
await expect(panel.getByText("No matches", { exact: true })).toBeVisible();
|
||||
await writeFile(path.join(workspace.repoPath, "many.txt"), "needle\n".repeat(250));
|
||||
await input.fill("needle");
|
||||
await expect(panel.getByText(/Results limited/)).toBeVisible();
|
||||
await panel.screenshot({ path: screenshot });
|
||||
}
|
||||
|
||||
export async function searchContentsByTouch(page: Page, query: string) {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Search", exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", {
|
||||
name: "Search commands, files, workspaces, and agents...",
|
||||
exact: true,
|
||||
})
|
||||
.fill("Search file contents");
|
||||
await page.getByText("Search file contents", { exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill(query);
|
||||
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openLiteralSourceOccurrence(
|
||||
page: Page,
|
||||
workspace: CreatedWorkspace,
|
||||
input: { path: string; content: string; result: string; cursor?: string; imageFirst?: boolean },
|
||||
screenshot: string,
|
||||
) {
|
||||
await writeFile(path.join(workspace.repoPath, input.path), input.content);
|
||||
await writeFile(path.join(workspace.repoPath, "leading.txt"), "different file");
|
||||
await workspace.navigateTo();
|
||||
if (input.imageFirst) {
|
||||
await page.keyboard.press("Meta+p");
|
||||
await page.getByRole("textbox", { name: "Search files...", exact: true }).fill(input.path);
|
||||
await page.getByTestId("command-center-panel").getByText(input.path, { exact: true }).click();
|
||||
await expect(page.getByTestId("image-file-preview")).toBeVisible();
|
||||
}
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("needle");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel.getByRole("button", { name: input.result, exact: true })).toBeVisible();
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("needle");
|
||||
await expect(panel.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
const source = page.getByTestId("file-source-editor");
|
||||
await expect(source).toContainText("needle");
|
||||
await expect(source).not.toContainText("different file");
|
||||
await expect(source.locator(".cm-selectionBackground")).toBeVisible();
|
||||
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
|
||||
if (input.cursor) await expect(page.getByLabel(input.cursor, { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: screenshot });
|
||||
}
|
||||
|
||||
export async function reportSkippedLargeFiles(page: Page, workspace: CreatedWorkspace) {
|
||||
// rg never opens a file above the host ceiling, so an unqualified "No matches" would claim the
|
||||
// whole workspace was read.
|
||||
await writeFile(path.join(workspace.repoPath, "large.txt"), `needle${" ".repeat(1024 * 1024)}`);
|
||||
await workspace.navigateTo();
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await input.fill("needle");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel.getByText("No matches", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
panel.getByText("Files over 1.0 MB were not searched", { exact: true }),
|
||||
).toBeVisible();
|
||||
// The occurrence-cap notice is a different claim and must not appear here.
|
||||
await expect(panel.getByText(/Results limited/)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function openLiteralBackslashFile(page: Page, workspace: CreatedWorkspace) {
|
||||
// A backslash is an ordinary character in a Unix file name; "a\b.txt" is not "a/b.txt".
|
||||
await writeFile(path.join(workspace.repoPath, "a\\b.txt"), "needle LITERAL FILE");
|
||||
await mkdir(path.join(workspace.repoPath, "a"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "a", "b.txt"), "THIS IS THE OTHER FILE");
|
||||
await workspace.navigateTo();
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("needle");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel.getByRole("button", { name: /a\\b\.txt:1:1/ })).toBeVisible();
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("LITERAL FILE");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
const source = page.getByTestId("file-source-editor");
|
||||
await expect(source).toContainText("LITERAL FILE");
|
||||
await expect(source).not.toContainText("THIS IS THE OTHER FILE");
|
||||
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening the same occurrence twice has to navigate the pane both times. The tab target is
|
||||
* deliberately identity-stable, so the second activation looks like a no-op unless it asks the
|
||||
* file-opening owner to navigate again.
|
||||
*/
|
||||
export async function reopenSameOccurrenceAfterMovingAway(page: Page, workspace: CreatedWorkspace) {
|
||||
const lines = Array.from(
|
||||
{ length: 1200 },
|
||||
(_value, index) => `export const filler${index} = ${index};`,
|
||||
);
|
||||
lines.push('const marker = "REOPEN_TARGET";');
|
||||
await writeFile(path.join(workspace.repoPath, "reopen.ts"), `${lines.join("\n")}\n`);
|
||||
await workspace.navigateTo();
|
||||
|
||||
const scroller = page.getByTestId("file-source-editor").locator(".cm-scroller");
|
||||
const offset = () => scroller.evaluate((element) => element.scrollTop);
|
||||
|
||||
await openReopenTarget(page);
|
||||
const revealed = await offset();
|
||||
expect(revealed).toBeGreaterThan(0);
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-selectionBackground"),
|
||||
).toBeInViewport();
|
||||
|
||||
// Move the caret to the top of the same file, inside the same editor instance.
|
||||
await page.getByTestId("file-source-editor").locator(".cm-content").click();
|
||||
await page.keyboard.press("ControlOrMeta+Home");
|
||||
await expect.poll(offset).toBeLessThan(revealed / 2);
|
||||
|
||||
await openReopenTarget(page);
|
||||
await expect
|
||||
.poll(offset, { message: "reopening the same occurrence must navigate the pane again" })
|
||||
.toBeGreaterThan(revealed / 2);
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-selectionBackground"),
|
||||
).toBeInViewport();
|
||||
// The editor instance is reused, so its history survives the second navigation.
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("REOPEN_TARGET");
|
||||
}
|
||||
|
||||
async function openReopenTarget(page: Page) {
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("REOPEN_TARGET");
|
||||
await expect(panel.getByRole("button", { name: /reopen\.ts:1201:/ })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Two files can share a base name and their nearest parent, and a real path is often wider than
|
||||
* the result column, so the exact workspace-relative path lives in the tooltip. Being in the DOM
|
||||
* is not enough: the tooltip has to be the thing painted at its own coordinates.
|
||||
*/
|
||||
export async function revealExactPathOnHover(page: Page, workspace: CreatedWorkspace) {
|
||||
const deep =
|
||||
"packages/app/src/command-center/workspace-content-search/internal/deeply/nested/result-path-presentation.ts";
|
||||
for (const relative of [
|
||||
"packages/app/src/utils/index.ts",
|
||||
"packages/server/src/utils/index.ts",
|
||||
deep,
|
||||
]) {
|
||||
const target = path.join(workspace.repoPath, relative);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, `export const shared = "SHARED_UTIL";\n`);
|
||||
}
|
||||
await workspace.navigateTo();
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("SHARED_UTIL");
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const rows = panel.getByRole("button", { name: /:1:24/ });
|
||||
await expect(rows).toHaveCount(3);
|
||||
const tip = page.getByTestId("content-search-row-path");
|
||||
|
||||
async function expectPaintedPath(row: Locator, expected: string) {
|
||||
await row.hover();
|
||||
await expect(tip).toHaveText(expected);
|
||||
// The reader must actually see it: whatever is painted at the tooltip's own coordinates has
|
||||
// to be the tooltip, not the panel that used to cover it.
|
||||
// A tooltip is pointer-transparent, so elementFromPoint reports the row underneath either
|
||||
// way, and its surface is the same white as the panel. What settles it is whether the pixels
|
||||
// in its own rectangle change when it opens: if the panel still covers it, they do not.
|
||||
const box = await tip.boundingBox();
|
||||
if (!box) throw new Error("tooltip has no box");
|
||||
const clip = { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
const shown = await page.screenshot({ clip });
|
||||
await page.mouse.move(2, 2);
|
||||
await expect(tip).toBeHidden();
|
||||
const hidden = await page.screenshot({ clip });
|
||||
expect(
|
||||
shown.equals(hidden),
|
||||
"the tooltip's own rectangle must change when it opens, or the reader cannot see it",
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// Same base name and same nearest parent: the resting labels cannot tell these apart.
|
||||
const app = rows.filter({ hasText: "…/utils/index.ts:1:24" }).first();
|
||||
const server = rows.filter({ hasText: "…/utils/index.ts:1:24" }).last();
|
||||
await expect(app).toHaveAccessibleName(/^packages\/app\/src\/utils\/index\.ts:1:24 /);
|
||||
await expect(server).toHaveAccessibleName(/^packages\/server\/src\/utils\/index\.ts:1:24 /);
|
||||
await expectPaintedPath(app, "packages/app/src/utils/index.ts:1:24");
|
||||
await expectPaintedPath(server, "packages/server/src/utils/index.ts:1:24");
|
||||
|
||||
// A path far wider than the result column still reads in full.
|
||||
await expectPaintedPath(
|
||||
rows.filter({ hasText: "result-path-presentation.ts" }).first(),
|
||||
`${deep}:1:24`,
|
||||
);
|
||||
return { panel, rows, tip };
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlay chrome the desktop panel owns: a backdrop that dismisses it, Escape that dismisses
|
||||
* it, and focus that goes back where it came from. Hosting the panel in the shared overlay root
|
||||
* rather than a native Modal must not change any of it.
|
||||
*/
|
||||
export async function keepDesktopOverlayChrome(page: Page, workspace: CreatedWorkspace) {
|
||||
await writeFile(path.join(workspace.repoPath, "chrome.ts"), 'const a = "CHROME_TARGET";\n');
|
||||
await workspace.navigateTo();
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
|
||||
// Backdrop dismisses the panel.
|
||||
const composer = composerLocator(page);
|
||||
await composer.click();
|
||||
await expect(composer).toBeFocused();
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await expect(input).toBeFocused();
|
||||
await page.mouse.click(page.viewportSize()!.width - 12, page.viewportSize()!.height - 12);
|
||||
await expect(panel).toBeHidden();
|
||||
|
||||
// Escape dismisses it and hands focus back to the composer it was opened from.
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await input.fill("CHROME_TARGET");
|
||||
await expect(panel.getByRole("button", { name: /chrome\.ts:1:/ })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
await expect(composer).toBeFocused();
|
||||
|
||||
// Ordinary commands and the Files scope still open over the same chrome.
|
||||
await page.keyboard.press("Meta+k");
|
||||
await expect(page.getByTestId("command-center-input")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Meta+p");
|
||||
await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkspaceContentSearch } from "./workspace-content-search";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
@@ -10,7 +11,8 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -36,6 +38,7 @@ import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
OverlayLayerProvider,
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
@@ -90,7 +93,18 @@ const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMut
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const SCOPE_LABELS = {
|
||||
files: {
|
||||
chip: "shell.commandCenter.files",
|
||||
placeholder: "shell.commandCenter.filePlaceholder",
|
||||
},
|
||||
content: {
|
||||
chip: "shell.commandCenter.content",
|
||||
placeholder: "shell.commandCenter.contentPlaceholder",
|
||||
},
|
||||
} as const;
|
||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||
const CONTENT_SEARCH_SNAP_POINTS = ["90%"];
|
||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||
|
||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
@@ -259,7 +273,7 @@ function useCommandCenterState(): CommandCenterState {
|
||||
error: fileSearchError,
|
||||
openFile,
|
||||
} = useWorkspaceFileSearch({
|
||||
enabled: open && (scope === "files" || Boolean(query.trim())),
|
||||
enabled: open && scope !== "content" && (scope === "files" || Boolean(query.trim())),
|
||||
query,
|
||||
});
|
||||
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
|
||||
@@ -584,6 +598,7 @@ export function CommandCenter() {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const contentKeyHandler = useRef<((key: string) => boolean) | null>(null);
|
||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||
const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
|
||||
@@ -700,18 +715,30 @@ export function CommandCenter() {
|
||||
onScroll: handleListScroll,
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
|
||||
// Every scope wears the same header; only the chip and the placeholder name it.
|
||||
const scopeLabels = state.scope ? SCOPE_LABELS[state.scope] : null;
|
||||
const scopeChipLabel = scopeLabels ? t(scopeLabels.chip) : null;
|
||||
const placeholder = t(scopeLabels?.placeholder ?? "shell.commandCenter.placeholder");
|
||||
// Content search owns arrows, Enter and Backspace while its scope is active; every other scope
|
||||
// is driven by the shared result list. One dispatcher so the input, submit and the web overlay
|
||||
// can never disagree about who is steering.
|
||||
const dispatchKey = useCallback(
|
||||
(key: string) =>
|
||||
state.scope === "content" ? (contentKeyHandler.current?.(key) ?? false) : state.key(key),
|
||||
[state],
|
||||
);
|
||||
const submit = useCallback(() => state.key("Enter"), [state]);
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => dispatchKey(key),
|
||||
[dispatchKey],
|
||||
);
|
||||
const submit = useCallback(() => dispatchKey("Enter"), [dispatchKey]);
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!state.key(event.key)) return false;
|
||||
if (!dispatchKey(event.key)) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
[state],
|
||||
[dispatchKey],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && state.open && !showBottomSheet,
|
||||
@@ -730,7 +757,9 @@ export function CommandCenter() {
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={COMMAND_CENTER_SNAP_POINTS}
|
||||
snapPoints={
|
||||
state.scope === "content" ? CONTENT_SEARCH_SNAP_POINTS : COMMAND_CENTER_SNAP_POINTS
|
||||
}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
@@ -744,9 +773,7 @@ export function CommandCenter() {
|
||||
accessible={false}
|
||||
>
|
||||
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
{scopeChipLabel ? <ScopeChip label={scopeChipLabel} onRemove={state.clearScope} /> : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
@@ -755,11 +782,7 @@ export function CommandCenter() {
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
placeholder={placeholder}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -770,49 +793,90 @@ export function CommandCenter() {
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
{state.scope === "content" ? (
|
||||
<WorkspaceContentSearch
|
||||
query={state.query}
|
||||
compact={isCompact}
|
||||
close={state.close}
|
||||
clearScope={state.clearScope}
|
||||
keyHandler={contentKeyHandler}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</>
|
||||
)}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
const desktopContent = (
|
||||
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
|
||||
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
initialValue={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<FlatList ref={listRef} {...commonListProps} />
|
||||
<View style={[styles.overlay, isWeb && { zIndex: modalLayer }]}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
testID="command-center-panel"
|
||||
style={[styles.panel, state.scope === "content" && styles.contentPanel]}
|
||||
>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{scopeChipLabel ? (
|
||||
<ScopeChip label={scopeChipLabel} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
initialValue={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={placeholder}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{state.scope === "content" ? (
|
||||
<WorkspaceContentSearch
|
||||
query={state.query}
|
||||
compact={isCompact}
|
||||
close={state.close}
|
||||
clearScope={state.clearScope}
|
||||
keyHandler={contentKeyHandler}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{fileSearchError}
|
||||
<FlatList ref={listRef} {...commonListProps} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
return hostDesktopOverlay(desktopContent, state.close);
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop web hosts the panel in the shared overlay root rather than React Native Web's `Modal`,
|
||||
* which paints in a plane of its own above it. Inside that plane an ordinary portal — the tooltip
|
||||
* on a result row — can never cover the panel however high its own z-index, so the row could not
|
||||
* show a reader its exact path. `adaptive-modal-sheet` already hosts desktop modals this way; see
|
||||
* docs/floating-panels.md. Native keeps the Modal.
|
||||
*/
|
||||
function hostDesktopOverlay(content: ReactElement, onRequestClose: () => void): ReactElement {
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
return (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={onRequestClose}>
|
||||
{content}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; label: string }) {
|
||||
@@ -841,7 +905,7 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
|
||||
accessibilityLabel={label}
|
||||
onPress={onRemove}
|
||||
style={styles.scopeChip}
|
||||
testID="command-center-files-scope"
|
||||
testID="command-center-scope"
|
||||
>
|
||||
<ThemedFolder size={14} strokeWidth={2.2} />
|
||||
<Text style={styles.scopeChipLabel}>{label}</Text>
|
||||
@@ -852,12 +916,14 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
paddingTop: theme.spacing[12],
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
||||
contentPanel: { width: 1000, maxWidth: "96%", height: "75%" },
|
||||
panel: {
|
||||
width: 640,
|
||||
height: 560,
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { FlatList, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { WorkspaceContentMatch } from "@getpaseo/protocol/messages";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { WorkspaceFileSource } from "@/file-pane/source";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import { describeResultPath } from "./internal/result-path";
|
||||
import {
|
||||
WorkspaceContentSearchModel,
|
||||
type ContentSearchSnapshot,
|
||||
type ContentSearchTransport,
|
||||
} from "./internal/model";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
/** The Command Center owns the field; this body only reacts to what was typed in it. */
|
||||
query: string;
|
||||
compact: boolean;
|
||||
close(): void;
|
||||
clearScope(): void;
|
||||
keyHandler: React.RefObject<((key: string) => boolean) | null>;
|
||||
}
|
||||
|
||||
export function WorkspaceContentSearch(props: Props) {
|
||||
const { keyHandler, close, clearScope } = props;
|
||||
const { t } = useTranslation();
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const workspaceId = selection?.workspaceId ?? null;
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const session = useSessionStore((state) => (serverId ? state.sessions[serverId] : undefined));
|
||||
const unavailable =
|
||||
!session?.client ||
|
||||
!cwd ||
|
||||
!serverId ||
|
||||
!workspaceId ||
|
||||
!session.serverInfo?.features?.workspaceContentSearch;
|
||||
useEffect(() => {
|
||||
if (!unavailable) return;
|
||||
keyHandler.current = (key) => {
|
||||
if (key === "Escape") close();
|
||||
else if (key === "Backspace") clearScope();
|
||||
return ["Enter", "ArrowUp", "ArrowDown", "Escape", "Backspace"].includes(key);
|
||||
};
|
||||
return () => {
|
||||
keyHandler.current = null;
|
||||
};
|
||||
}, [unavailable, keyHandler, close, clearScope]);
|
||||
if (!session?.client || !cwd || !serverId || !workspaceId)
|
||||
return <Text style={styles.status}>{t("shell.commandCenter.contentUnavailable")}</Text>;
|
||||
// COMPAT(workspaceContentSearch): added in v0.8.0, remove after 2027-03-09 once daemon floor >= v0.8.0.
|
||||
if (!session.serverInfo?.features?.workspaceContentSearch)
|
||||
return <Text style={styles.status}>{t("shell.commandCenter.contentUpdateHost")}</Text>;
|
||||
return (
|
||||
<ContentSearch
|
||||
key={`${serverId}:${workspaceId}`}
|
||||
{...props}
|
||||
transport={session.client}
|
||||
cwd={cwd}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentSearch({
|
||||
transport,
|
||||
cwd,
|
||||
serverId,
|
||||
workspaceId,
|
||||
query,
|
||||
compact,
|
||||
close,
|
||||
clearScope,
|
||||
keyHandler,
|
||||
}: Props & {
|
||||
transport: ContentSearchTransport;
|
||||
cwd: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const [previewPage, setPreviewPage] = useState(false);
|
||||
const closeRef = useRef(close);
|
||||
closeRef.current = close;
|
||||
const model = useMemo(
|
||||
() =>
|
||||
new WorkspaceContentSearchModel({
|
||||
cwd,
|
||||
transport,
|
||||
open(location) {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
const layout = useWorkspaceLayoutStore.getState();
|
||||
openWorkspaceFileInFocusedPane({
|
||||
location,
|
||||
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
closeExplorerAfterOpen: true,
|
||||
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
||||
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
|
||||
layout.openTab({ workspaceKey, target, intent: "reveal", placement }),
|
||||
focusWorkspaceTab: layout.focusTab,
|
||||
requestFileNavigation: layout.requestFileNavigation,
|
||||
});
|
||||
closeRef.current();
|
||||
},
|
||||
}),
|
||||
[cwd, transport, serverId, workspaceId],
|
||||
);
|
||||
useEffect(() => () => model.dispose(), [model]);
|
||||
// The field lives in the Command Center header, so the typed value is a prop; this is the only
|
||||
// bridge from it into the model that owns the search.
|
||||
useEffect(() => {
|
||||
setPreviewPage(false);
|
||||
model.setQuery(query);
|
||||
}, [model, query]);
|
||||
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const listRef = useRef<FlatList>(null);
|
||||
useEffect(() => {
|
||||
if (state.matches.length && (!compact || !previewPage))
|
||||
listRef.current?.scrollToIndex({
|
||||
index: state.activeIndex,
|
||||
animated: false,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, [compact, previewPage, state.activeIndex, state.matches]);
|
||||
const key = useCallback(
|
||||
(pressed: string) => {
|
||||
if (pressed === "Escape") {
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Backspace" && !state.query) {
|
||||
clearScope();
|
||||
return true;
|
||||
}
|
||||
if (pressed === "ArrowDown" || pressed === "ArrowUp") {
|
||||
model.move(pressed === "ArrowDown" ? 1 : -1);
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Enter") {
|
||||
model.openSelected();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[close, clearScope, model, state.query],
|
||||
);
|
||||
useEffect(() => {
|
||||
keyHandler.current = key;
|
||||
return () => {
|
||||
if (keyHandler.current === key) keyHandler.current = null;
|
||||
};
|
||||
}, [keyHandler, key]);
|
||||
const select = useCallback(
|
||||
(index: number) => {
|
||||
model.select(index);
|
||||
if (compact) setPreviewPage(true);
|
||||
},
|
||||
[model, compact],
|
||||
);
|
||||
const back = useCallback(() => setPreviewPage(false), []);
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: WorkspaceContentMatch; index: number }) => (
|
||||
<ResultRow item={item} index={index} selected={state.activeIndex === index} select={select} />
|
||||
),
|
||||
[select, state.activeIndex],
|
||||
);
|
||||
const emptyState = useMemo(
|
||||
() => <SearchState state={state} retry={model.retry} />,
|
||||
[model.retry, state],
|
||||
);
|
||||
const limitNotice = useMemo(() => <ResultLimit limited={state.limited} />, [state.limited]);
|
||||
return (
|
||||
<View style={styles.body}>
|
||||
{!compact || !previewPage ? (
|
||||
<View style={[styles.results, compact && styles.full]}>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={state.matches}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyExtractor={resultKey}
|
||||
getItemLayout={resultLayout}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={emptyState}
|
||||
ListFooterComponent={limitNotice}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{!compact || previewPage ? (
|
||||
<ContentPreview state={state} model={model} compact={compact} back={back} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
item,
|
||||
index,
|
||||
selected,
|
||||
select,
|
||||
}: {
|
||||
item: WorkspaceContentMatch;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
select(index: number): void;
|
||||
}) {
|
||||
const onPress = useCallback(() => select(index), [index, select]);
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
// Same hover/selection idiom as the Command Center's own result rows, one tint apart so the
|
||||
// keyboard cursor that drives the preview stays distinguishable from the pointer.
|
||||
const style = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
(Boolean(hovered) || pressed) && styles.hovered,
|
||||
selected && styles.selected,
|
||||
],
|
||||
[selected],
|
||||
);
|
||||
const line = useMemo(() => describeMatchLine(item), [item]);
|
||||
const file = useMemo(() => describeResultPath(item.path), [item.path]);
|
||||
const location = `${item.path}:${item.line}:${item.columnStart}`;
|
||||
return (
|
||||
// The resting row shows the file and its nearest parent, which two files can share
|
||||
// (packages/app/src/utils/index.ts and packages/server/src/utils/index.ts). The tooltip is
|
||||
// where the exact workspace-relative path lives; it is not bounded by the column's width.
|
||||
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${location} ${item.snippet}`}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-pressed={isWeb ? selected : undefined}
|
||||
onPress={onPress}
|
||||
style={style}
|
||||
>
|
||||
<Text style={styles.snippet} numberOfLines={1}>
|
||||
{line.before}
|
||||
<Text style={styles.match}>{line.match}</Text>
|
||||
{line.after}
|
||||
</Text>
|
||||
{/* The name and coordinates are pinned; only the parent context may shrink, so a deep
|
||||
path loses its middle rather than the identity the reader is scanning for. */}
|
||||
<View style={styles.location}>
|
||||
{file.directory ? (
|
||||
<Text style={styles.locationDirectory} numberOfLines={1}>
|
||||
{file.directory}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={styles.locationFile} numberOfLines={1}>
|
||||
{file.name}:{item.line}:{item.columnStart}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" maxWidth={520} testID="content-search-row-path">
|
||||
<Text style={styles.tooltipPath}>{location}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** The row is one line wide, so the match has to survive indentation and long-line offsets. */
|
||||
function describeMatchLine(item: WorkspaceContentMatch) {
|
||||
// Only a run-up long enough to be worth cutting is cut, so an ordinary line keeps its start.
|
||||
const cut = item.snippetMatchStart > SNIPPET_LEAD * 2;
|
||||
const from = cut ? item.snippetMatchStart - SNIPPET_LEAD : 0;
|
||||
const before = item.snippet.slice(from, item.snippetMatchStart);
|
||||
return {
|
||||
before: cut ? `…${before}` : before.trimStart(),
|
||||
match: item.snippet.slice(item.snippetMatchStart, item.snippetMatchEnd),
|
||||
after: item.snippet.slice(item.snippetMatchEnd),
|
||||
};
|
||||
}
|
||||
function resultKey(item: WorkspaceContentMatch) {
|
||||
return `${item.path}:${item.line}:${item.columnStart}`;
|
||||
}
|
||||
function resultLayout(_data: ArrayLike<WorkspaceContentMatch> | null | undefined, index: number) {
|
||||
return { length: RESULT_ROW_HEIGHT, offset: index * RESULT_ROW_HEIGHT, index };
|
||||
}
|
||||
|
||||
/** Idle stays blank: the header placeholder already says what the field does. */
|
||||
function SearchState({ state, retry }: { state: ContentSearchSnapshot; retry(): void }) {
|
||||
const { t } = useTranslation();
|
||||
if (state.status === "searching")
|
||||
return (
|
||||
<View style={styles.status} accessibilityLiveRegion="polite">
|
||||
<ThemedSpinner size={14} />
|
||||
<Text style={styles.muted}>{t("shell.commandCenter.searchingFiles")}</Text>
|
||||
</View>
|
||||
);
|
||||
if (state.status === "error")
|
||||
return (
|
||||
<View style={styles.status} accessibilityLiveRegion="polite">
|
||||
<Text style={[styles.muted, styles.error]}>{state.message}</Text>
|
||||
<Button variant="outline" size="sm" onPress={retry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
if (state.status === "ready")
|
||||
return (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>
|
||||
{/* "No matches" alone claims the whole workspace was read. Files above the host's ceiling
|
||||
are never opened, so the empty state has to say so where the claim is made. */}
|
||||
{state.maxFileBytes ? (
|
||||
<Text style={styles.emptyDetail}>
|
||||
{t("shell.commandCenter.contentSkippedLargeFiles", {
|
||||
size: formatFileSize(state.maxFileBytes),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function ResultLimit({ limited }: { limited: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!limited) return null;
|
||||
return <Text style={styles.limit}>{t("shell.commandCenter.contentLimited")}</Text>;
|
||||
}
|
||||
|
||||
function ContentPreview({
|
||||
state,
|
||||
model,
|
||||
compact,
|
||||
back,
|
||||
}: {
|
||||
state: ContentSearchSnapshot;
|
||||
model: WorkspaceContentSearchModel;
|
||||
compact: boolean;
|
||||
back(): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const match = state.matches[state.activeIndex];
|
||||
const preview = state.preview;
|
||||
const location = useMemo(
|
||||
() =>
|
||||
match
|
||||
? {
|
||||
path: match.path,
|
||||
lineStart: match.line,
|
||||
columnStart: match.columnStart,
|
||||
columnEnd: match.columnEnd,
|
||||
expectedText: match.text,
|
||||
}
|
||||
: null,
|
||||
[match],
|
||||
);
|
||||
return (
|
||||
<View style={styles.preview}>
|
||||
{/* On desktop the selected row already names the file and Enter opens it, so the preview
|
||||
carries no header. The compact flow pages away from the list and still needs both. */}
|
||||
{compact && match ? (
|
||||
<View style={styles.previewHeader}>
|
||||
<Button variant="ghost" size="sm" onPress={back}>
|
||||
{t("shell.commandCenter.contentBack")}
|
||||
</Button>
|
||||
<Text style={styles.previewPath} numberOfLines={1} ellipsizeMode="head">
|
||||
{match.path}
|
||||
</Text>
|
||||
<Button variant="outline" size="sm" onPress={model.openSelected}>
|
||||
{t("shell.commandCenter.contentOpen")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{preview.status === "loading" ? (
|
||||
<View
|
||||
style={styles.status}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={t("shell.commandCenter.contentLoadingPreview")}
|
||||
>
|
||||
<ThemedSpinner size={14} />
|
||||
</View>
|
||||
) : null}
|
||||
{preview.status === "error" ? (
|
||||
<View style={styles.status} accessibilityLiveRegion="polite">
|
||||
<Text style={[styles.muted, styles.error]}>{preview.message}</Text>
|
||||
<Button variant="outline" size="sm" onPress={model.retryPreview}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{preview.status === "ready" && location ? (
|
||||
<WorkspaceFileSource
|
||||
content={preview.content}
|
||||
filename={preview.path}
|
||||
size={preview.size}
|
||||
navigationRevision={state.activeIndex}
|
||||
location={location}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Denser than the Command Center's own two-line row: these results are scanned, not read, and a
|
||||
// search wants as many occurrences on screen as stay legible.
|
||||
const RESULT_ROW_HEIGHT = 44;
|
||||
// Characters of the matched line kept ahead of the match itself.
|
||||
const SNIPPET_LEAD = 24;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
body: { flex: 1, minHeight: 0, flexDirection: "row" },
|
||||
// A result row is two truncated single lines, so extra panel width buys the list nothing and
|
||||
// buys the code preview everything: the column is a fixed reading width, capped so a narrow
|
||||
// desktop panel cannot starve the preview.
|
||||
results: {
|
||||
width: 360,
|
||||
maxWidth: "45%",
|
||||
minHeight: 0,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
},
|
||||
full: { width: "100%", maxWidth: "100%", borderRightWidth: 0 },
|
||||
row: {
|
||||
height: RESULT_ROW_HEIGHT,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
hovered: { backgroundColor: theme.colors.surface1 },
|
||||
selected: { backgroundColor: theme.colors.surface2 },
|
||||
// Three levels: the match is what the reader is looking for, the rest of the line is the
|
||||
// context it sits in, and the location is context for that.
|
||||
snippet: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: 17,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
match: {
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: "600",
|
||||
backgroundColor: theme.colors.terminal.selectionBackground,
|
||||
},
|
||||
location: { flexDirection: "row", alignItems: "baseline" },
|
||||
locationFile: {
|
||||
flexShrink: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 15,
|
||||
},
|
||||
locationDirectory: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 15,
|
||||
},
|
||||
preview: { flex: 1, minWidth: 0, minHeight: 0 },
|
||||
previewHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
previewPath: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
status: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
muted: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
|
||||
error: { color: theme.colors.statusDanger },
|
||||
tooltipPath: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
empty: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
emptyDetail: {
|
||||
textAlign: "center",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
limit: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,234 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { FileReadResult } from "@getpaseo/client";
|
||||
import type {
|
||||
WorkspaceContentMatch,
|
||||
WorkspaceContentSearchResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { WorkspaceContentSearchModel, type ContentSearchTransport } from "./model";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function match(path: string, text = "NEEDLE"): WorkspaceContentMatch {
|
||||
return {
|
||||
path,
|
||||
line: 1,
|
||||
columnStart: 1,
|
||||
columnEnd: text.length + 1,
|
||||
text,
|
||||
snippet: text,
|
||||
snippetMatchStart: 0,
|
||||
snippetMatchEnd: text.length,
|
||||
};
|
||||
}
|
||||
function result(matches: WorkspaceContentMatch[]): WorkspaceContentSearchResult {
|
||||
return { status: "ok", matches, limited: false };
|
||||
}
|
||||
function file(path: string, text: string): FileReadResult {
|
||||
return {
|
||||
path,
|
||||
kind: "text",
|
||||
mime: "text/plain",
|
||||
bytes: new TextEncoder().encode(text),
|
||||
size: text.length,
|
||||
modifiedAt: "2026-09-09",
|
||||
};
|
||||
}
|
||||
|
||||
test("obsolete queries cannot restore results and opening carries the original case-insensitive match", async () => {
|
||||
const first = deferred<WorkspaceContentSearchResult>();
|
||||
const second = deferred<WorkspaceContentSearchResult>();
|
||||
const startedFirst = deferred<void>();
|
||||
const startedSecond = deferred<void>();
|
||||
const signals: AbortSignal[] = [];
|
||||
const opened: unknown[] = [];
|
||||
const transport: ContentSearchTransport = {
|
||||
searchWorkspaceContent: ({ query }, { signal }) => {
|
||||
signals.push(signal);
|
||||
if (query === "old") {
|
||||
startedFirst.resolve();
|
||||
return first.promise;
|
||||
}
|
||||
startedSecond.resolve();
|
||||
return second.promise;
|
||||
},
|
||||
readFile: async (path) => file(path, "NEEDLE"),
|
||||
};
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
transport,
|
||||
open: (location) => opened.push(location),
|
||||
debounceMs: 0,
|
||||
});
|
||||
try {
|
||||
model.setQuery("old");
|
||||
await startedFirst.promise;
|
||||
model.setQuery("needle");
|
||||
await startedSecond.promise;
|
||||
expect(signals[0].aborted).toBe(true);
|
||||
second.resolve(result([match("a.ts")]));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
first.resolve(result([match("obsolete.ts")]));
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
query: "needle",
|
||||
status: "ready",
|
||||
matches: [match("a.ts")],
|
||||
preview: { status: "ready", content: "NEEDLE" },
|
||||
});
|
||||
model.openSelected();
|
||||
expect(opened).toEqual([
|
||||
{ path: "a.ts", lineStart: 1, columnStart: 1, columnEnd: 7, expectedText: "NEEDLE" },
|
||||
]);
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("selection bounds preview work to one read plus the latest target, and reads current saved content", async () => {
|
||||
const started = deferred<void>();
|
||||
const searched = deferred<WorkspaceContentSearchResult>();
|
||||
const a = deferred<FileReadResult>();
|
||||
const c = deferred<FileReadResult>();
|
||||
const paths: string[] = [];
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: () => {
|
||||
started.resolve();
|
||||
return searched.promise;
|
||||
},
|
||||
readFile: (_cwd, path) => {
|
||||
paths.push(path);
|
||||
return path === "a" ? a.promise : c.promise;
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("needle");
|
||||
await started.promise;
|
||||
searched.resolve(result([match("a"), match("b"), match("c")]));
|
||||
await Promise.resolve();
|
||||
model.select(1);
|
||||
model.select(2);
|
||||
expect(paths).toEqual(["a"]);
|
||||
a.resolve(file("a", "NEEDLE"));
|
||||
await Promise.resolve();
|
||||
expect(paths).toEqual(["a", "c"]);
|
||||
c.resolve(file("c", "changed"));
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().preview).toMatchObject({
|
||||
status: "ready",
|
||||
path: "c",
|
||||
content: "changed",
|
||||
});
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new query refreshes the saved preview even when its selected path is unchanged", async () => {
|
||||
const reads: Array<ReturnType<typeof deferred<FileReadResult>>> = [];
|
||||
const searches: Array<ReturnType<typeof deferred<WorkspaceContentSearchResult>>> = [];
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: () => {
|
||||
const pending = deferred<WorkspaceContentSearchResult>();
|
||||
searches.push(pending);
|
||||
return pending.promise;
|
||||
},
|
||||
readFile: () => {
|
||||
const pending = deferred<FileReadResult>();
|
||||
reads.push(pending);
|
||||
return pending.promise;
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("first");
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
searches[0].resolve(result([match("same.ts")]));
|
||||
await Promise.resolve();
|
||||
model.setQuery("second");
|
||||
await expect.poll(() => searches.length).toBe(2);
|
||||
searches[1].resolve(result([match("same.ts")]));
|
||||
await Promise.resolve();
|
||||
reads[0].resolve(file("same.ts", "old saved text"));
|
||||
await Promise.resolve();
|
||||
expect(reads).toHaveLength(2);
|
||||
reads[1].resolve(file("same.ts", "NEEDLE current saved text"));
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().preview).toMatchObject({
|
||||
status: "ready",
|
||||
content: "NEEDLE current saved text",
|
||||
});
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("textual image occurrences expose saved source without changing the read kind", async () => {
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: async () => result([match("icon.svg")]),
|
||||
readFile: async () => ({
|
||||
...file("icon.svg", "<svg>NEEDLE</svg>"),
|
||||
kind: "image",
|
||||
mime: "image/svg+xml",
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("needle");
|
||||
await expect.poll(() => model.getSnapshot().preview.status).toBe("ready");
|
||||
expect(model.getSnapshot().preview).toMatchObject({ content: "<svg>NEEDLE</svg>" });
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("revisiting a file re-reads it, so a file deleted since the search stops previewing", async () => {
|
||||
const reads: string[] = [];
|
||||
let deleted = false;
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: async () => result([match("a.ts"), match("b.ts")]),
|
||||
readFile: async (_cwd, path) => {
|
||||
reads.push(path);
|
||||
if (path === "a.ts" && deleted) throw new Error("ENOENT: no such file");
|
||||
return file(path, `NEEDLE in ${path}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("needle");
|
||||
await expect.poll(() => model.getSnapshot().preview.status).toBe("ready");
|
||||
expect(reads).toEqual(["a.ts"]);
|
||||
model.move(1);
|
||||
await expect.poll(() => reads).toEqual(["a.ts", "b.ts"]);
|
||||
deleted = true;
|
||||
model.move(-1);
|
||||
await expect.poll(() => model.getSnapshot().preview).toMatchObject({ status: "error" });
|
||||
expect(reads).toEqual(["a.ts", "b.ts", "a.ts"]);
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
WorkspaceContentMatch,
|
||||
WorkspaceContentSearchResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import type { FileReadResult } from "@getpaseo/client";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
|
||||
export interface ContentSearchTransport {
|
||||
searchWorkspaceContent(
|
||||
input: { cwd: string; query: string },
|
||||
options: { signal: AbortSignal },
|
||||
): Promise<WorkspaceContentSearchResult>;
|
||||
readFile(
|
||||
cwd: string,
|
||||
path: string,
|
||||
requestId?: string,
|
||||
maxBytes?: number,
|
||||
): Promise<FileReadResult>;
|
||||
}
|
||||
type Preview =
|
||||
| { status: "empty" | "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "ready"; path: string; content: string; size: number };
|
||||
export interface ContentSearchSnapshot {
|
||||
query: string;
|
||||
status: "idle" | "searching" | "ready" | "error";
|
||||
message: string;
|
||||
matches: WorkspaceContentMatch[];
|
||||
limited: boolean;
|
||||
/** Per-file byte ceiling the host applied, so an empty result can say what it did not read. */
|
||||
maxFileBytes: number | null;
|
||||
activeIndex: number;
|
||||
preview: Preview;
|
||||
}
|
||||
|
||||
/** The daemon refuses to read more, and rg refuses to search a file bigger than this. */
|
||||
const MAX_PREVIEW_BYTES = 1_048_576;
|
||||
export class WorkspaceContentSearchModel {
|
||||
private snapshot: ContentSearchSnapshot = {
|
||||
query: "",
|
||||
status: "idle",
|
||||
message: "",
|
||||
matches: [],
|
||||
limited: false,
|
||||
maxFileBytes: null,
|
||||
activeIndex: 0,
|
||||
preview: { status: "empty" },
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
private controller: AbortController | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private disposed = false;
|
||||
private reading = false;
|
||||
private file: { path: string; content: string; size: number } | null = null;
|
||||
constructor(
|
||||
private readonly options: {
|
||||
cwd: string;
|
||||
transport: ContentSearchTransport;
|
||||
open(location: WorkspaceFileLocation): void;
|
||||
debounceMs?: number;
|
||||
},
|
||||
) {}
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
getSnapshot = () => this.snapshot;
|
||||
private update(next: Partial<ContentSearchSnapshot>) {
|
||||
this.snapshot = { ...this.snapshot, ...next };
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
setQuery = (query: string) => {
|
||||
this.controller?.abort();
|
||||
this.file = null;
|
||||
clearTimeout(this.timer);
|
||||
this.update({
|
||||
query,
|
||||
status: query ? "searching" : "idle",
|
||||
message: "",
|
||||
matches: [],
|
||||
limited: false,
|
||||
maxFileBytes: null,
|
||||
activeIndex: 0,
|
||||
preview: { status: "empty" },
|
||||
});
|
||||
if (!query || this.disposed) return;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
this.timer = setTimeout(
|
||||
() => void this.search(query, controller),
|
||||
this.options.debounceMs ?? 100,
|
||||
);
|
||||
};
|
||||
private async search(query: string, controller: AbortController) {
|
||||
try {
|
||||
const result = await this.options.transport.searchWorkspaceContent(
|
||||
{ cwd: this.options.cwd, query },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (this.disposed || controller.signal.aborted) return;
|
||||
if (result.status === "error") {
|
||||
this.update({ status: "error", message: result.message });
|
||||
return;
|
||||
}
|
||||
this.update({
|
||||
status: "ready",
|
||||
matches: result.matches,
|
||||
limited: result.limited,
|
||||
maxFileBytes: result.maxFileBytes ?? null,
|
||||
});
|
||||
this.select(0);
|
||||
} catch (error) {
|
||||
if (!this.disposed && !controller.signal.aborted)
|
||||
this.update({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "File search failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
select = (index: number) => {
|
||||
const activeIndex = Math.max(0, Math.min(index, this.snapshot.matches.length - 1));
|
||||
this.update({ activeIndex });
|
||||
void this.preview();
|
||||
};
|
||||
move = (direction: number) =>
|
||||
this.select(
|
||||
(this.snapshot.activeIndex + direction + this.snapshot.matches.length) %
|
||||
(this.snapshot.matches.length || 1),
|
||||
);
|
||||
private async preview() {
|
||||
const match = this.snapshot.matches[this.snapshot.activeIndex];
|
||||
if (!match) return;
|
||||
// Only the file on screen is retained. Re-reading on every arrival is what lets the preview
|
||||
// tell the truth about a file that changed or disappeared since the search ran; see the
|
||||
// deleted-file recovery case in the browser spec before trading it for fewer reads.
|
||||
if (this.file?.path === match.path) {
|
||||
this.update({ preview: { status: "ready", ...this.file } });
|
||||
return;
|
||||
}
|
||||
this.update({ preview: { status: "loading" } });
|
||||
if (this.reading) return;
|
||||
this.reading = true;
|
||||
const path = match.path;
|
||||
const query = this.controller;
|
||||
try {
|
||||
const file = await this.options.transport.readFile(
|
||||
this.options.cwd,
|
||||
path,
|
||||
undefined,
|
||||
MAX_PREVIEW_BYTES,
|
||||
);
|
||||
if (this.disposed) return;
|
||||
if (query === this.controller) {
|
||||
const content = explorerFileFromReadResult(file).content;
|
||||
if (content === undefined) throw new Error("This file is no longer text");
|
||||
this.file = { path, content, size: file.size };
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!this.disposed &&
|
||||
query === this.controller &&
|
||||
this.snapshot.matches[this.snapshot.activeIndex]?.path === path
|
||||
) {
|
||||
this.update({
|
||||
preview: {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "Preview unavailable",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
this.reading = false;
|
||||
}
|
||||
if (!this.disposed) void this.preview();
|
||||
}
|
||||
retry = () => {
|
||||
this.file = null;
|
||||
this.setQuery(this.snapshot.query);
|
||||
};
|
||||
retryPreview = () => {
|
||||
this.file = null;
|
||||
void this.preview();
|
||||
};
|
||||
openSelected = () => {
|
||||
const match = this.snapshot.matches[this.snapshot.activeIndex];
|
||||
if (!match || this.snapshot.status !== "ready") return;
|
||||
this.options.open({
|
||||
path: match.path,
|
||||
lineStart: match.line,
|
||||
columnStart: match.columnStart,
|
||||
columnEnd: match.columnEnd,
|
||||
expectedText: match.text,
|
||||
});
|
||||
};
|
||||
dispose = () => {
|
||||
this.disposed = true;
|
||||
this.controller?.abort();
|
||||
clearTimeout(this.timer);
|
||||
this.listeners.clear();
|
||||
this.file = null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeResultPath } from "./result-path";
|
||||
|
||||
describe("describeResultPath", () => {
|
||||
it("keeps a root file whole", () => {
|
||||
expect(describeResultPath("README.md")).toEqual({ directory: "", name: "README.md" });
|
||||
});
|
||||
|
||||
it("keeps a shallow path whole", () => {
|
||||
expect(describeResultPath("docs/architecture.md")).toEqual({
|
||||
directory: "docs/",
|
||||
name: "architecture.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("elides the head of a deep path and keeps the distinguishing parent", () => {
|
||||
// These two rows are both "index.tsx"; the parents are the only thing telling them apart.
|
||||
expect(
|
||||
describeResultPath("packages/app/src/command-center/workspace-content-search/index.tsx"),
|
||||
).toEqual({ directory: "…/workspace-content-search/", name: "index.tsx" });
|
||||
expect(describeResultPath("packages/app/src/file-pane/source/index.tsx")).toEqual({
|
||||
directory: "…/source/",
|
||||
name: "index.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a literal backslash in a file name out of the directory", () => {
|
||||
expect(describeResultPath("a\\b.txt")).toEqual({ directory: "", name: "a\\b.txt" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* How a result row names its file.
|
||||
*
|
||||
* A flat list of occurrences is full of repeated base names — four `index.tsx` rows tell the
|
||||
* reader nothing. The nearest directories are what distinguish them, and they are also what an
|
||||
* end-truncated path throws away first. So the row keeps the file name and the closest parents,
|
||||
* and marks the elided head with a leading ellipsis.
|
||||
*/
|
||||
export interface ResultPathParts {
|
||||
/** Directory context immediately above the file, already elided at the head when deeper. */
|
||||
directory: string;
|
||||
/** File name, which is never truncated away. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
const PARENTS_KEPT = 1;
|
||||
|
||||
export function describeResultPath(path: string): ResultPathParts {
|
||||
const segments = path.split("/");
|
||||
const name = segments.pop() ?? path;
|
||||
if (segments.length === 0) return { directory: "", name };
|
||||
const kept = segments.slice(-PARENTS_KEPT);
|
||||
const elided = segments.length > kept.length;
|
||||
return { directory: `${elided ? "…/" : ""}${kept.join("/")}/`, name };
|
||||
}
|
||||
@@ -4,12 +4,12 @@ export interface WorkspaceFileSearchEntry {
|
||||
directory: string;
|
||||
}
|
||||
|
||||
/** Splits a workspace-relative path the host produced, where "/" is the only separator. */
|
||||
export function describeWorkspaceFilePath(path: string): WorkspaceFileSearchEntry {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const separator = normalized.lastIndexOf("/");
|
||||
const separator = path.lastIndexOf("/");
|
||||
return {
|
||||
path: normalized,
|
||||
name: separator >= 0 ? normalized.slice(separator + 1) : normalized,
|
||||
directory: separator >= 0 ? normalized.slice(0, separator) : "",
|
||||
path,
|
||||
name: separator >= 0 ? path.slice(separator + 1) : path,
|
||||
directory: separator >= 0 ? path.slice(0, separator) : "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ describe("describeWorkspaceFilePath", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes Windows separators before opening or presenting a file", () => {
|
||||
expect(describeWorkspaceFilePath("src\\components\\message.tsx")).toEqual({
|
||||
path: "src/components/message.tsx",
|
||||
name: "message.tsx",
|
||||
directory: "src/components",
|
||||
it("treats a backslash as part of the file name, because the host already sent separators as /", () => {
|
||||
expect(describeWorkspaceFilePath("a\\b.txt")).toEqual({
|
||||
path: "a\\b.txt",
|
||||
name: "a\\b.txt",
|
||||
directory: "",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -128,8 +128,8 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
||||
(path: string) => {
|
||||
if (!serverId || !workspaceId) return;
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
openWorkspaceFileFromExplorer({
|
||||
filePath: path,
|
||||
openWorkspaceFileInFocusedPane({
|
||||
location: { path },
|
||||
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
closeExplorerAfterOpen: true,
|
||||
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
||||
@@ -141,6 +141,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
||||
placement,
|
||||
}),
|
||||
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
|
||||
requestFileNavigation: useWorkspaceLayoutStore.getState().requestFileNavigation,
|
||||
});
|
||||
},
|
||||
[serverId, workspaceId],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -327,10 +328,30 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
],
|
||||
);
|
||||
|
||||
const allActions = useMemo(
|
||||
() => [
|
||||
...actions,
|
||||
{
|
||||
id: "workspace-content-search",
|
||||
group: "actions" as const,
|
||||
groupRank: 0,
|
||||
rank: 10,
|
||||
visibility: "query" as const,
|
||||
keywords: ["search", "find", "contents", "files", "text"],
|
||||
run: () => useKeyboardShortcutsStore.getState().setCommandCenterOpen(true, "content"),
|
||||
presentation: {
|
||||
kind: "action" as const,
|
||||
title: t("shell.commandCenter.contentTitle"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[actions, t],
|
||||
);
|
||||
useCommandCenterActions({
|
||||
sourceId: "workspace",
|
||||
enabled: Boolean(serverId && cwd),
|
||||
actions,
|
||||
actions: allActions,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
|
||||
import {
|
||||
@@ -119,6 +119,7 @@ export function CompactExplorerSidebarHost({
|
||||
[openTab],
|
||||
);
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const requestFileNavigation = useWorkspaceLayoutStore((state) => state.requestFileNavigation);
|
||||
|
||||
const handleOpenExplorer = useCallback(() => {
|
||||
if (!model?.workspaceRoot) {
|
||||
@@ -136,16 +137,24 @@ export function CompactExplorerSidebarHost({
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceFileFromExplorer({
|
||||
filePath,
|
||||
openWorkspaceFileInFocusedPane({
|
||||
location: { path: filePath },
|
||||
persistenceKey: model.persistenceKey,
|
||||
closeExplorerAfterOpen: presentation === "overlay",
|
||||
showMobileAgent,
|
||||
openWorkspaceTabInFocusedPane,
|
||||
focusWorkspaceTab,
|
||||
requestFileNavigation,
|
||||
});
|
||||
},
|
||||
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
|
||||
[
|
||||
focusWorkspaceTab,
|
||||
model,
|
||||
openWorkspaceTabInFocusedPane,
|
||||
presentation,
|
||||
requestFileNavigation,
|
||||
showMobileAgent,
|
||||
],
|
||||
);
|
||||
|
||||
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
|
||||
@@ -72,3 +72,9 @@ describe("resolveFilePreviewReadTarget", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves literal whitespace for a source read target", () => {
|
||||
expect(
|
||||
resolveFilePreviewReadTarget({ path: " leading.txt ", workspaceRoot: "/workspace " }),
|
||||
).toEqual({ cwd: "/workspace ", path: " leading.txt " });
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ export function resolveFilePreviewReadTarget(input: {
|
||||
path: string;
|
||||
workspaceRoot?: string;
|
||||
}): FilePreviewReadTarget | null {
|
||||
const previewPath = input.path.trim();
|
||||
const previewPath = input.path;
|
||||
if (!previewPath) {
|
||||
return null;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function resolveFilePreviewReadTarget(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceRoot = input.workspaceRoot?.trim();
|
||||
const workspaceRoot = input.workspaceRoot;
|
||||
if (!isAbsolutePath(previewPath)) {
|
||||
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
|
||||
return null;
|
||||
|
||||
@@ -32,3 +32,20 @@ describe("explorerFileFromReadResult", () => {
|
||||
expect(embedded.hasBom).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps SVG image identity and exposes valid UTF-8 source for occurrence navigation", () => {
|
||||
const file = {
|
||||
...textRead(new TextEncoder().encode("<svg>needle</svg>")),
|
||||
kind: "image" as const,
|
||||
mime: "image/svg+xml",
|
||||
path: "icon.svg",
|
||||
};
|
||||
expect(explorerFileFromReadResult(file)).toMatchObject({
|
||||
kind: "image",
|
||||
content: "<svg>needle</svg>",
|
||||
});
|
||||
expect(explorerFileFromReadResult({ ...file, bytes: new Uint8Array([255]) })).toMatchObject({
|
||||
kind: "image",
|
||||
content: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,21 @@ import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ExplorerFile } from "@/stores/session-store";
|
||||
|
||||
export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
|
||||
const isText = file.kind === "text";
|
||||
// SVG keeps its normal image presentation while exposing its UTF-8 source to line targets.
|
||||
const isText = file.kind === "text" || file.mime === "image/svg+xml";
|
||||
let content: string | undefined;
|
||||
if (isText) {
|
||||
try {
|
||||
content = new TextDecoder("utf-8", { fatal: true }).decode(file.bytes);
|
||||
} catch (error) {
|
||||
if (file.kind === "text") throw error;
|
||||
}
|
||||
}
|
||||
return {
|
||||
path: file.path,
|
||||
kind: file.kind,
|
||||
encoding: isText ? "utf-8" : "none",
|
||||
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
|
||||
content,
|
||||
hasBom: isText && hasUtf8Bom(file.bytes),
|
||||
mimeType: file.mime,
|
||||
size: file.size,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
|
||||
import type { FileEditorStatus } from "./editor/model";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
||||
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -115,12 +116,6 @@ export function FilePanelBar({
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
chrome: {
|
||||
flexShrink: 0,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language";
|
||||
import { searchKeymap } from "@codemirror/search";
|
||||
import {
|
||||
EditorView,
|
||||
drawSelection,
|
||||
@@ -42,7 +41,6 @@ export function editorBaseExtensions(onSave: () => void) {
|
||||
indentWithTab,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap,
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -63,6 +61,13 @@ export function editorTheme(theme: EditorVisualTheme) {
|
||||
fontFamily: theme.monoFont,
|
||||
lineHeight: "1.45",
|
||||
},
|
||||
".cm-panels": { backgroundColor: theme.background, color: theme.foreground },
|
||||
".cm-panels-top": { borderBottom: "none" },
|
||||
".cm-searchMatch": { backgroundColor: theme.selection },
|
||||
".cm-searchMatch-selected": {
|
||||
backgroundColor: theme.selection,
|
||||
outline: `1px solid ${theme.cursor}`,
|
||||
},
|
||||
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
|
||||
".cm-gutters": {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { FileFind, FileFindModel } from "../find/index.web";
|
||||
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import { getCM, vim } from "@replit/codemirror-vim";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { FileEditorModel } from "./model";
|
||||
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
|
||||
|
||||
@@ -38,6 +40,8 @@ export function FileEditorView({
|
||||
onCursorChange,
|
||||
onVimModeChange,
|
||||
}: FileEditorViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [find] = useState(() => new FileFindModel());
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
@@ -54,6 +58,7 @@ export function FileEditorView({
|
||||
doc: values.content,
|
||||
extensions: [
|
||||
vimCompartment.of(values.vimEnabled ? vim() : []),
|
||||
find.extension,
|
||||
...editorBaseExtensions(() => void values.model.save()),
|
||||
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
|
||||
wrappingCompartment.of(wrappingForFile(values.filename)),
|
||||
@@ -81,7 +86,7 @@ export function FileEditorView({
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [find]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
@@ -99,15 +104,12 @@ export function FileEditorView({
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !location.lineStart) return;
|
||||
const lineStart = Math.min(location.lineStart, view.state.doc.lines);
|
||||
const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines);
|
||||
const from = view.state.doc.line(lineStart).from;
|
||||
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
|
||||
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
|
||||
selection: { anchor: from, head: to },
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
}, [location.lineEnd, location.lineStart, navigationRevision]);
|
||||
}, [location, navigationRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
@@ -140,16 +142,34 @@ export function FileEditorView({
|
||||
return () => cm.off("vim-mode-change", handleModeChange);
|
||||
}, [onVimModeChange, vimEnabled]);
|
||||
|
||||
const changed = resolveWorkspaceFileSelection(
|
||||
snapshot.content.replace(/\r\n?/g, "\n"),
|
||||
location,
|
||||
).changed;
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
data-testid="file-source-editor"
|
||||
aria-label={`Source editor for ${filename}`}
|
||||
style={HOST_STYLE}
|
||||
/>
|
||||
<div style={FRAME_STYLE}>
|
||||
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
data-testid="file-source-editor"
|
||||
aria-label={`Source editor for ${filename}`}
|
||||
style={HOST_STYLE}
|
||||
/>
|
||||
<FileFind model={find} editor={viewRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const remoteUpdate = Annotation.define<boolean>();
|
||||
// The Find widget floats inside this frame, so it stays relative; the change notice is the
|
||||
// only row that shares the column with the editor host.
|
||||
const FRAME_STYLE = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
} as const;
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { View, type View as ViewInstance } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaneFind, type PaneFindHandle } from "@/pane-find";
|
||||
import { usePaneFocusIfAny } from "@/panels/pane-context";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { FileFindModel } from "./model.web";
|
||||
|
||||
export { FileFindModel } from "./model.web";
|
||||
|
||||
export function FileFind({
|
||||
model,
|
||||
editor,
|
||||
}: {
|
||||
model: FileFindModel;
|
||||
editor: RefObject<EditorView | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const widget = useRef<PaneFindHandle>(null);
|
||||
// A source view can also render outside any pane, such as the Command Center content
|
||||
// preview. Pane Find belongs to the pane, so there is nothing to open there.
|
||||
const pane = usePaneFocusIfAny();
|
||||
const active = useRetainedPanelActive();
|
||||
// RN Web hands the underlying DOM element to a View ref; the model measures it.
|
||||
const setWidgetNode = useCallback(
|
||||
(node: ViewInstance | null) => model.setWidgetNode(node as unknown as HTMLElement | null),
|
||||
[model],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!pane?.isInteractive || !active) return;
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === "f"
|
||||
) {
|
||||
event.preventDefault();
|
||||
model.open(editor.current);
|
||||
widget.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [active, editor, model, pane?.isInteractive]);
|
||||
|
||||
const replace = useMemo(
|
||||
() =>
|
||||
state.readOnly
|
||||
? undefined
|
||||
: {
|
||||
value: state.replacement,
|
||||
onChange: model.setReplacement,
|
||||
onReplace: model.replace,
|
||||
onReplaceAll: model.replaceAll,
|
||||
},
|
||||
[model, state.readOnly, state.replacement],
|
||||
);
|
||||
if (!pane || !state.open) return null;
|
||||
const total = `${state.total}${state.limited ? "+" : ""}`;
|
||||
let status = "";
|
||||
if (state.query) {
|
||||
if (state.total === 0) status = t("paneFind.noMatches");
|
||||
else if (state.current) status = t("paneFind.position", { current: state.current, total });
|
||||
else status = t("paneFind.total", { total });
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={[styles.overlay, state.placement === "top" ? styles.overlayTop : styles.overlayBottom]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View ref={setWidgetNode} style={styles.widget}>
|
||||
<PaneFind
|
||||
ref={widget}
|
||||
query={state.query}
|
||||
status={status}
|
||||
canNavigate={state.total > 0}
|
||||
onQueryChange={model.setSearch}
|
||||
onNext={model.next}
|
||||
onPrevious={model.previous}
|
||||
onClose={model.close}
|
||||
replace={replace}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// The widget floats over the content; the model flips the corner when it would
|
||||
// otherwise sit on top of the active match.
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
left: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
alignItems: "flex-end",
|
||||
zIndex: 1,
|
||||
},
|
||||
// Bounds the widget to the pane; PaneFind's own maxWidth resolves against this.
|
||||
widget: { maxWidth: "100%" },
|
||||
overlayTop: { top: theme.spacing[2] },
|
||||
overlayBottom: { bottom: theme.spacing[2] },
|
||||
}));
|
||||
@@ -0,0 +1,255 @@
|
||||
import { EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView, keymap, type Panel, type ViewUpdate } from "@codemirror/view";
|
||||
import {
|
||||
closeSearchPanel,
|
||||
findNext,
|
||||
findPrevious,
|
||||
getSearchQuery,
|
||||
openSearchPanel,
|
||||
replaceAll,
|
||||
replaceNext,
|
||||
search,
|
||||
searchKeymap,
|
||||
SearchQuery,
|
||||
setSearchQuery,
|
||||
} from "@codemirror/search";
|
||||
|
||||
const MATCH_STATUS_LIMIT = 10_000;
|
||||
|
||||
/** Which corner of the editor the floating Find widget occupies. */
|
||||
export type FindPlacement = "top" | "bottom";
|
||||
|
||||
/** CodeMirror owns query, matching, selection, replacement, and panel lifetime. */
|
||||
export class FileFindModel {
|
||||
private view: EditorView | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
private matches: Array<{ from: number; to: number }> = [];
|
||||
private widget: HTMLElement | null = null;
|
||||
private widgetResize: ResizeObserver | null = null;
|
||||
private snapshot = {
|
||||
open: false,
|
||||
placement: "top" as FindPlacement,
|
||||
query: "",
|
||||
replacement: "",
|
||||
current: 0,
|
||||
total: 0,
|
||||
limited: false,
|
||||
readOnly: true,
|
||||
};
|
||||
readonly subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
readonly getSnapshot = () => this.snapshot;
|
||||
|
||||
readonly extension: Extension = [
|
||||
// The widget floats over the editor as a sibling overlay, so this panel is an
|
||||
// inert placeholder that only reports whether Find is open. Hide the placeholder
|
||||
// itself, never the shared panel container — Go to line's dialog lives there too.
|
||||
EditorView.theme({ ".cm-panel.paseo-file-find": { display: "none" } }),
|
||||
// Reveal matches clear of the widget instead of underneath it.
|
||||
EditorView.scrollMargins.of((view) => {
|
||||
const clearance = this.clearance(view);
|
||||
if (!clearance) return null;
|
||||
return this.snapshot.placement === "top" ? { top: clearance } : { bottom: clearance };
|
||||
}),
|
||||
search({ literal: true, top: true, createPanel: (view) => this.createPanel(view) }),
|
||||
EditorState.transactionExtender.of((transaction) => {
|
||||
// CodeMirror seeds every search-opening command from the selection. A
|
||||
// single-line field cannot represent CR/LF, so keep the previous query.
|
||||
const multilineSeed = transaction.effects.some(
|
||||
(effect) => effect.is(setSearchQuery) && /[\r\n]/.test(effect.value.search),
|
||||
);
|
||||
if (!multilineSeed) return null;
|
||||
return { effects: setSearchQuery.of(getSearchQuery(transaction.startState)) };
|
||||
}),
|
||||
keymap.of(searchKeymap.filter((binding) => binding.key !== "Mod-f")),
|
||||
];
|
||||
|
||||
readonly open = (view = this.view) => {
|
||||
if (view) openSearchPanel(view);
|
||||
};
|
||||
readonly close = () => {
|
||||
if (this.view) {
|
||||
closeSearchPanel(this.view);
|
||||
this.view.focus();
|
||||
}
|
||||
};
|
||||
readonly next = () => {
|
||||
if (this.view) findNext(this.view);
|
||||
};
|
||||
readonly previous = () => {
|
||||
if (this.view) findPrevious(this.view);
|
||||
};
|
||||
readonly replace = () => {
|
||||
if (this.view) replaceNext(this.view);
|
||||
};
|
||||
readonly replaceAll = () => {
|
||||
if (this.view) replaceAll(this.view);
|
||||
};
|
||||
readonly setReplacement = (replacement: string) =>
|
||||
this.setQuery(this.snapshot.query, replacement);
|
||||
readonly setSearch = (query: string) => {
|
||||
this.setQuery(query, this.snapshot.replacement);
|
||||
if (this.view && this.matches.length > 0) {
|
||||
this.view.dispatch({ selection: { anchor: this.view.state.selection.main.from } });
|
||||
findNext(this.view);
|
||||
}
|
||||
};
|
||||
|
||||
private setQuery(query: string, replacement: string) {
|
||||
this.view?.dispatch({
|
||||
effects: setSearchQuery.of(
|
||||
new SearchQuery({ search: query, replace: replacement, literal: true }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The widget renders outside CodeMirror, so the editor cannot see it. Handing the
|
||||
* node here lets this model — which already owns reveal — measure the obstruction.
|
||||
*/
|
||||
readonly setWidgetNode = (node: HTMLElement | null) => {
|
||||
if (this.widget === node) return;
|
||||
this.widgetResize?.disconnect();
|
||||
this.widgetResize = null;
|
||||
this.widget = node;
|
||||
if (!node) return;
|
||||
this.widgetResize = new ResizeObserver(() => this.reposition());
|
||||
this.widgetResize.observe(node);
|
||||
this.reposition();
|
||||
};
|
||||
|
||||
private createPanel(view: EditorView): Panel {
|
||||
this.view = view;
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "paseo-file-find";
|
||||
return {
|
||||
dom,
|
||||
top: true,
|
||||
mount: () => {
|
||||
this.snapshot = { ...this.snapshot, open: true, placement: "top" };
|
||||
this.update(view, true);
|
||||
},
|
||||
update: (update: ViewUpdate) => {
|
||||
const queryChanged = !getSearchQuery(update.startState).eq(getSearchQuery(update.state));
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.selectionSet ||
|
||||
queryChanged ||
|
||||
update.geometryChanged ||
|
||||
update.viewportChanged
|
||||
)
|
||||
this.update(view, update.docChanged || queryChanged);
|
||||
},
|
||||
destroy: () => {
|
||||
this.snapshot = { ...this.snapshot, open: false, placement: "top" };
|
||||
this.publish();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Vertical space the widget takes out of the editor, including its inset. */
|
||||
private clearance(view: EditorView): number {
|
||||
const widget = this.widget;
|
||||
if (!widget || !this.snapshot.open) return 0;
|
||||
const box = widget.getBoundingClientRect();
|
||||
if (!box.height) return 0;
|
||||
const editor = view.scrollDOM.getBoundingClientRect();
|
||||
const clearance =
|
||||
this.snapshot.placement === "top" ? box.bottom - editor.top : editor.bottom - box.top;
|
||||
return Math.max(0, clearance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the widget to the opposite corner when it sits on top of the active match
|
||||
* and the editor has no scroll range left to reveal it. Placement only changes
|
||||
* when the current corner collides, so navigating matches never makes it bounce.
|
||||
*
|
||||
* Measurement runs through `requestMeasure` because CodeMirror refuses layout
|
||||
* reads while an update is in progress, and every caller here sits inside one.
|
||||
*/
|
||||
private reposition() {
|
||||
const view = this.view;
|
||||
if (!view || !this.widget || !this.snapshot.open) return;
|
||||
view.requestMeasure<FindPlacement>({
|
||||
key: this,
|
||||
read: () => this.measurePlacement(view),
|
||||
write: (placement) => {
|
||||
if (placement === this.snapshot.placement) return;
|
||||
this.snapshot = { ...this.snapshot, placement };
|
||||
this.publish();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private measurePlacement(view: EditorView): FindPlacement {
|
||||
const current = this.snapshot.placement;
|
||||
const widget = this.widget;
|
||||
if (!widget) return current;
|
||||
const selection = view.state.selection.main;
|
||||
if (selection.empty) return current;
|
||||
const from = view.coordsAtPos(selection.from);
|
||||
const to = view.coordsAtPos(selection.to);
|
||||
if (!from || !to) return current;
|
||||
const clearance = this.clearance(view);
|
||||
if (!clearance) return current;
|
||||
const box = widget.getBoundingClientRect();
|
||||
const editor = view.scrollDOM.getBoundingClientRect();
|
||||
const match = {
|
||||
top: Math.min(from.top, to.top),
|
||||
bottom: Math.max(from.bottom, to.bottom),
|
||||
right: Math.max(from.right, to.right),
|
||||
};
|
||||
const hits = (placement: FindPlacement) => {
|
||||
if (match.right <= box.left) return false;
|
||||
return placement === "top"
|
||||
? match.top < editor.top + clearance && match.bottom > editor.top
|
||||
: match.bottom > editor.bottom - clearance && match.top < editor.bottom;
|
||||
};
|
||||
if (!hits(current)) return current;
|
||||
const other: FindPlacement = current === "top" ? "bottom" : "top";
|
||||
return hits(other) ? current : other;
|
||||
}
|
||||
|
||||
private update(view: EditorView, recount: boolean) {
|
||||
const query = getSearchQuery(view.state);
|
||||
let limited = this.snapshot.limited;
|
||||
if (recount) {
|
||||
this.matches = [];
|
||||
limited = false;
|
||||
if (query.valid) {
|
||||
const cursor = query.getCursor(view.state);
|
||||
for (let match = cursor.next(); !match.done; match = cursor.next()) {
|
||||
if (this.matches.length === MATCH_STATUS_LIMIT) {
|
||||
limited = true;
|
||||
break;
|
||||
}
|
||||
this.matches.push(match.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
const selection = view.state.selection.main;
|
||||
const current =
|
||||
this.matches.findIndex(
|
||||
(match) => match.from === selection.from && match.to === selection.to,
|
||||
) + 1;
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
query: query.search,
|
||||
replacement: query.replace,
|
||||
current,
|
||||
total: this.matches.length,
|
||||
limited,
|
||||
readOnly: view.state.readOnly,
|
||||
};
|
||||
this.publish();
|
||||
this.reposition();
|
||||
}
|
||||
|
||||
private publish() {
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,14 @@ import { FileMarkdownPreview } from "./markdown-preview";
|
||||
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
||||
import { createFileObservationSource } from "./editor/observation-source";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
import { FileSourceView } from "./source/view";
|
||||
import { WorkspaceFileSource } from "./source";
|
||||
import type { FileConflictAlertState } from "./conflict-alert";
|
||||
import type { LiveFileModel } from "./live-file/model";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -58,24 +59,6 @@ interface FilePreviewBodyProps {
|
||||
|
||||
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function ReadonlySource({
|
||||
preview,
|
||||
filename,
|
||||
@@ -87,32 +70,13 @@ function ReadonlySource({
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const { t } = useTranslation();
|
||||
const visualTheme = useMemo(
|
||||
() => ({
|
||||
colorScheme: theme.colorScheme,
|
||||
background: theme.colors.surface0,
|
||||
foreground: theme.colors.foreground,
|
||||
cursor: theme.colors.terminal.cursor,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
border: theme.colors.border,
|
||||
selection: theme.colors.terminal.selectionBackground,
|
||||
monoFont: theme.fontFamily.mono,
|
||||
codeFontSize: theme.fontSize.code,
|
||||
syntax: theme.colors.syntax,
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
return (
|
||||
<FileSourceView
|
||||
<WorkspaceFileSource
|
||||
content={preview.content ?? ""}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
size={preview.size}
|
||||
theme={visualTheme}
|
||||
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -122,7 +86,7 @@ function TooLargeSource({ size }: { size?: number }) {
|
||||
return (
|
||||
<View style={styles.centerState} testID="file-source-too-large">
|
||||
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
|
||||
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
|
||||
{size ? <Text style={styles.binaryMetaText}>{formatFileSize(size)}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -164,7 +128,7 @@ function FilePreviewBody({
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
if (preview.kind === "text" || (location.lineStart && preview.content !== undefined)) {
|
||||
if (renderKind === "html") {
|
||||
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
|
||||
return (
|
||||
@@ -214,7 +178,7 @@ function FilePreviewBody({
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize(preview.size)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -239,8 +203,8 @@ export function FilePane({
|
||||
const supportsEditing = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||
const normalizedWorkspaceRoot = workspaceRoot;
|
||||
const normalizedFilePath = location.path || null;
|
||||
const readTarget = useMemo(
|
||||
() =>
|
||||
normalizedFilePath
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { FileSourceView } from "./view";
|
||||
|
||||
/** Read-only source preview. The source module owns platform rendering and its theme. */
|
||||
export function WorkspaceFileSource(props: {
|
||||
content: string;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
size: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const visualTheme = useMemo(
|
||||
() => ({
|
||||
colorScheme: theme.colorScheme,
|
||||
background: theme.colors.surface0,
|
||||
foreground: theme.colors.foreground,
|
||||
cursor: theme.colors.terminal.cursor,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
border: theme.colors.border,
|
||||
selection: theme.colors.terminal.selectionBackground,
|
||||
monoFont: theme.fontFamily.mono,
|
||||
codeFontSize: theme.fontSize.code,
|
||||
syntax: theme.colors.syntax,
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
return (
|
||||
<FileSourceView
|
||||
{...props}
|
||||
theme={visualTheme}
|
||||
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, Text, View, type ListRenderItem } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
@@ -58,14 +59,16 @@ function VirtualizedSource({
|
||||
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
|
||||
presentation: "highlighted" | "plain";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const listRef = useRef<FlatList<SourceLine>>(null);
|
||||
const lines = useMemo(() => {
|
||||
const source = content.replace(/\r\n?/g, "\n");
|
||||
if (presentation === "highlighted")
|
||||
return highlightCode(content, filename).map((tokens, index) => ({
|
||||
return highlightCode(source, filename).map((tokens, index) => ({
|
||||
number: index + 1,
|
||||
tokens,
|
||||
}));
|
||||
return content
|
||||
return source
|
||||
.split("\n")
|
||||
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
|
||||
}, [content, filename, presentation]);
|
||||
@@ -77,29 +80,63 @@ function VirtualizedSource({
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, [lines.length, location.lineStart, navigationRevision]);
|
||||
const selectedLine = lines[(location.lineStart ?? 1) - 1]?.tokens
|
||||
.map((token) => token.text)
|
||||
.join("");
|
||||
const changed =
|
||||
location.expectedText !== undefined &&
|
||||
selectedLine?.slice((location.columnStart ?? 1) - 1, (location.columnEnd ?? 1) - 1) !==
|
||||
location.expectedText;
|
||||
const renderLine = useCallback(
|
||||
({ item }: { item: SourceLine }) => (
|
||||
<SourceLineView
|
||||
line={item}
|
||||
start={!changed && item.number === location.lineStart ? location.columnStart : undefined}
|
||||
end={!changed && item.number === location.lineStart ? location.columnEnd : undefined}
|
||||
/>
|
||||
),
|
||||
[changed, location],
|
||||
);
|
||||
return (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={lines}
|
||||
keyExtractor={sourceLineKey}
|
||||
initialNumToRender={24}
|
||||
windowSize={9}
|
||||
getItemLayout={sourceLineLayout}
|
||||
renderItem={renderSourceLine}
|
||||
/>
|
||||
<View style={styles.root}>
|
||||
{changed ? (
|
||||
<Text accessibilityRole="alert" style={styles.notice}>
|
||||
{t("shell.commandCenter.contentChanged")}
|
||||
</Text>
|
||||
) : null}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={lines}
|
||||
keyExtractor={sourceLineKey}
|
||||
initialNumToRender={24}
|
||||
windowSize={9}
|
||||
getItemLayout={sourceLineLayout}
|
||||
extraData={location}
|
||||
renderItem={renderLine}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceLineView({ line }: { line: SourceLine }) {
|
||||
function SourceLineView({ line, start, end }: { line: SourceLine; start?: number; end?: number }) {
|
||||
let offset = 0;
|
||||
return (
|
||||
<View style={styles.line}>
|
||||
<Text style={styles.gutter}>{line.number}</Text>
|
||||
<Text selectable style={styles.text}>
|
||||
{line.tokens.map((token) => (
|
||||
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
{line.tokens.map((token) => {
|
||||
const tokenStart = offset;
|
||||
offset += token.text.length;
|
||||
const from = Math.max(0, Math.min(token.text.length, (start ?? 1) - 1 - tokenStart));
|
||||
const to = Math.max(from, Math.min(token.text.length, (end ?? 1) - 1 - tokenStart));
|
||||
return (
|
||||
<Text key={`${tokenStart}:${token.style}`} style={syntaxTokenStyleFor(token.style)}>
|
||||
{token.text.slice(0, from)}
|
||||
<Text style={styles.match}>{token.text.slice(from, to)}</Text>
|
||||
{token.text.slice(to)}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -113,9 +150,14 @@ function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index
|
||||
return { length: 20, offset: index * 20, index };
|
||||
}
|
||||
|
||||
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flex: 1 },
|
||||
notice: {
|
||||
padding: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
match: { backgroundColor: theme.colors.terminal.selectionBackground },
|
||||
unsupported: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FileFind, FileFindModel } from "../find/index.web";
|
||||
import { Compartment, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { EditorView, drawSelection } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { editorTheme } from "../editor/extensions.web";
|
||||
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
|
||||
@@ -59,6 +61,8 @@ function ReadonlyCodeMirror({
|
||||
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
|
||||
presentation: Exclude<SourcePresentation, "unsupported">;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [find] = useState(() => new FileFindModel());
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const initial = useRef({ content, filename, presentation, theme });
|
||||
@@ -71,7 +75,15 @@ function ReadonlyCodeMirror({
|
||||
state: EditorState.create({
|
||||
doc: values.content,
|
||||
extensions: [
|
||||
find.extension,
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.contentAttributes.of({
|
||||
tabindex: "0",
|
||||
"aria-label": `Source for ${values.filename}`,
|
||||
}),
|
||||
// The preview is never focused, so the browser draws no selection of its own; the
|
||||
// exact occurrence has to be painted by CodeMirror.
|
||||
drawSelection(),
|
||||
EditorView.editable.of(false),
|
||||
languageCompartment.of(
|
||||
languageFor({ filename: values.filename, presentation: values.presentation }),
|
||||
@@ -85,7 +97,7 @@ function ReadonlyCodeMirror({
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [find]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
@@ -105,12 +117,22 @@ function ReadonlyCodeMirror({
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !location.lineStart) return;
|
||||
const line = Math.min(location.lineStart, view.state.doc.lines);
|
||||
const from = view.state.doc.line(line).from;
|
||||
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
|
||||
}, [location.lineStart, navigationRevision]);
|
||||
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: to },
|
||||
// A match far into a long line lands at the scroller edge without the horizontal centre.
|
||||
effects: EditorView.scrollIntoView(from, { x: "center", y: "center" }),
|
||||
});
|
||||
}, [content, location, navigationRevision]);
|
||||
|
||||
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
|
||||
const changed = resolveWorkspaceFileSelection(content.replace(/\r\n?/g, "\n"), location).changed;
|
||||
return (
|
||||
<div style={FRAME_STYLE}>
|
||||
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
|
||||
<div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />
|
||||
<FileFind model={find} editor={viewRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function languageFor(input: {
|
||||
@@ -122,6 +144,16 @@ function languageFor(input: {
|
||||
: [];
|
||||
}
|
||||
|
||||
// The Find widget floats inside this frame, so it stays relative; the change notice is the
|
||||
// only row that shares the column with the editor host.
|
||||
const FRAME_STYLE = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
} as const;
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
const UNSUPPORTED_STYLE = {
|
||||
alignItems: "center",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ar: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "بحث",
|
||||
placeholder: "بحث في اللوحة",
|
||||
close: "إغلاق البحث",
|
||||
matches: "نتائج البحث",
|
||||
previous: "التطابق السابق",
|
||||
next: "التطابق التالي",
|
||||
toggleReplace: "إظهار الاستبدال",
|
||||
replaceWith: "استبدال بـ",
|
||||
replace: "استبدال",
|
||||
replaceAll: "استبدال الكل",
|
||||
noMatches: "لا توجد تطابقات",
|
||||
position: "{{current}} من {{total}}",
|
||||
total: "{{total}} تطابقات",
|
||||
},
|
||||
common: {
|
||||
back: "خلف",
|
||||
loading: "تحميل...",
|
||||
@@ -58,6 +73,18 @@ export const ar: TranslationResources = {
|
||||
close: "إغلاق القائمة",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "البحث في محتوى الملفات",
|
||||
content: "محتوى الملفات",
|
||||
contentPlaceholder: "البحث في الملفات المحفوظة...",
|
||||
contentUnavailable: "مساحة العمل غير متاحة",
|
||||
contentUpdateHost: "حدّث هذا المضيف للبحث في محتوى الملفات",
|
||||
contentBack: "العودة إلى النتائج",
|
||||
contentOpen: "فتح",
|
||||
contentLoadingPreview: "جارٍ تحميل المعاينة...",
|
||||
contentLimited: "النتائج محدودة — حدّد البحث أكثر",
|
||||
contentSkippedLargeFiles: "لم يتم البحث في الملفات الأكبر من {{size}}",
|
||||
contentChanged: "تغيّر الملف — لم تعد المطابقة في هذا الموضع",
|
||||
|
||||
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
|
||||
filePlaceholder: "البحث في الملفات...",
|
||||
searchingFiles: "جارٍ البحث في الملفات...",
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
export const en = {
|
||||
paneFind: {
|
||||
title: "Find",
|
||||
placeholder: "Find in pane",
|
||||
close: "Close Find",
|
||||
matches: "Find matches",
|
||||
previous: "Previous match",
|
||||
next: "Next match",
|
||||
toggleReplace: "Toggle replace",
|
||||
replaceWith: "Replace with",
|
||||
replace: "Replace",
|
||||
replaceAll: "Replace all",
|
||||
noMatches: "No matches",
|
||||
position: "{{current}} of {{total}}",
|
||||
total: "{{total}} matches",
|
||||
},
|
||||
common: {
|
||||
back: "Back",
|
||||
loading: "Loading...",
|
||||
@@ -55,6 +70,18 @@ export const en = {
|
||||
close: "Close menu",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Search file contents",
|
||||
content: "File contents",
|
||||
contentPlaceholder: "Search saved file contents...",
|
||||
contentUnavailable: "Workspace unavailable",
|
||||
contentUpdateHost: "Update this host to search file contents",
|
||||
contentBack: "Back to results",
|
||||
contentOpen: "Open",
|
||||
contentLoadingPreview: "Loading preview...",
|
||||
contentLimited: "Results limited — refine your search",
|
||||
contentSkippedLargeFiles: "Files over {{size}} were not searched",
|
||||
contentChanged: "File changed — saved match is no longer at this location",
|
||||
|
||||
placeholder: "Search commands, files, workspaces, and agents...",
|
||||
filePlaceholder: "Search files...",
|
||||
searchingFiles: "Searching files...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const es: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "Buscar",
|
||||
placeholder: "Buscar en el panel",
|
||||
close: "Cerrar búsqueda",
|
||||
matches: "Coincidencias",
|
||||
previous: "Coincidencia anterior",
|
||||
next: "Siguiente coincidencia",
|
||||
toggleReplace: "Mostrar reemplazo",
|
||||
replaceWith: "Reemplazar con",
|
||||
replace: "Reemplazar",
|
||||
replaceAll: "Reemplazar todo",
|
||||
noMatches: "Sin coincidencias",
|
||||
position: "{{current}} de {{total}}",
|
||||
total: "{{total}} coincidencias",
|
||||
},
|
||||
common: {
|
||||
back: "Atrás",
|
||||
loading: "Cargando...",
|
||||
@@ -58,6 +73,18 @@ export const es: TranslationResources = {
|
||||
close: "Cerrar menú",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Buscar contenido de archivos",
|
||||
content: "Contenido de archivos",
|
||||
contentPlaceholder: "Buscar contenido guardado...",
|
||||
contentUnavailable: "Espacio de trabajo no disponible",
|
||||
contentUpdateHost: "Actualiza este host para buscar contenido",
|
||||
contentBack: "Volver a resultados",
|
||||
contentOpen: "Abrir",
|
||||
contentLoadingPreview: "Cargando vista previa...",
|
||||
contentLimited: "Resultados limitados — precisa la búsqueda",
|
||||
contentSkippedLargeFiles: "No se buscó en archivos de más de {{size}}",
|
||||
contentChanged: "El archivo cambió — la coincidencia ya no está aquí",
|
||||
|
||||
placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...",
|
||||
filePlaceholder: "Buscar archivos...",
|
||||
searchingFiles: "Buscando archivos...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const fr: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "Rechercher",
|
||||
placeholder: "Rechercher dans le panneau",
|
||||
close: "Fermer la recherche",
|
||||
matches: "Résultats de recherche",
|
||||
previous: "Résultat précédent",
|
||||
next: "Résultat suivant",
|
||||
toggleReplace: "Afficher le remplacement",
|
||||
replaceWith: "Remplacer par",
|
||||
replace: "Remplacer",
|
||||
replaceAll: "Tout remplacer",
|
||||
noMatches: "Aucun résultat",
|
||||
position: "{{current}} sur {{total}}",
|
||||
total: "{{total}} résultats",
|
||||
},
|
||||
common: {
|
||||
back: "Dos",
|
||||
loading: "Chargement...",
|
||||
@@ -59,6 +74,18 @@ export const fr: TranslationResources = {
|
||||
close: "Fermer le menu",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Rechercher dans les fichiers",
|
||||
content: "Contenu des fichiers",
|
||||
contentPlaceholder: "Rechercher dans les fichiers enregistrés...",
|
||||
contentUnavailable: "Espace de travail indisponible",
|
||||
contentUpdateHost: "Mettez cet hôte à jour pour rechercher dans les fichiers",
|
||||
contentBack: "Retour aux résultats",
|
||||
contentOpen: "Ouvrir",
|
||||
contentLoadingPreview: "Chargement de l’aperçu...",
|
||||
contentLimited: "Résultats limités — précisez la recherche",
|
||||
contentSkippedLargeFiles: "Les fichiers de plus de {{size}} n’ont pas été analysés",
|
||||
contentChanged: "Fichier modifié — la correspondance n’est plus à cet emplacement",
|
||||
|
||||
placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...",
|
||||
filePlaceholder: "Rechercher des fichiers...",
|
||||
searchingFiles: "Recherche de fichiers...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ja: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "検索",
|
||||
placeholder: "ペイン内を検索",
|
||||
close: "検索を閉じる",
|
||||
matches: "検索結果",
|
||||
previous: "前の一致",
|
||||
next: "次の一致",
|
||||
toggleReplace: "置換を切り替え",
|
||||
replaceWith: "置換後の文字列",
|
||||
replace: "置換",
|
||||
replaceAll: "すべて置換",
|
||||
noMatches: "一致なし",
|
||||
position: "{{current}} / {{total}}",
|
||||
total: "{{total}} 件の一致",
|
||||
},
|
||||
common: {
|
||||
back: "戻る",
|
||||
loading: "読み込み中...",
|
||||
@@ -58,6 +73,18 @@ export const ja: TranslationResources = {
|
||||
close: "メニューを閉じる",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "ファイル内容を検索",
|
||||
content: "ファイル内容",
|
||||
contentPlaceholder: "保存済みファイルの内容を検索...",
|
||||
contentUnavailable: "ワークスペースを利用できません",
|
||||
contentUpdateHost: "ファイル内容を検索するにはホストを更新してください",
|
||||
contentBack: "検索結果に戻る",
|
||||
contentOpen: "開く",
|
||||
contentLoadingPreview: "プレビューを読み込み中...",
|
||||
contentLimited: "結果が上限に達しました — 検索を絞り込んでください",
|
||||
contentSkippedLargeFiles: "{{size}} を超えるファイルは検索されていません",
|
||||
contentChanged: "ファイルが変更され、この位置に一致する内容はありません",
|
||||
|
||||
placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...",
|
||||
filePlaceholder: "ファイルを検索...",
|
||||
searchingFiles: "ファイルを検索中...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ko: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "찾기",
|
||||
placeholder: "패널에서 찾기",
|
||||
close: "찾기 닫기",
|
||||
matches: "검색 결과",
|
||||
previous: "이전 일치 항목",
|
||||
next: "다음 일치 항목",
|
||||
toggleReplace: "바꾸기 표시 전환",
|
||||
replaceWith: "바꿀 내용",
|
||||
replace: "바꾸기",
|
||||
replaceAll: "모두 바꾸기",
|
||||
noMatches: "일치 항목 없음",
|
||||
position: "{{current}} / {{total}}",
|
||||
total: "일치 항목 {{total}}개",
|
||||
},
|
||||
common: {
|
||||
back: "뒤로",
|
||||
loading: "불러오는 중...",
|
||||
@@ -58,6 +73,18 @@ export const ko: TranslationResources = {
|
||||
close: "메뉴 닫기",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "파일 내용 검색",
|
||||
content: "파일 내용",
|
||||
contentPlaceholder: "저장된 파일 내용 검색...",
|
||||
contentUnavailable: "작업 공간을 사용할 수 없음",
|
||||
contentUpdateHost: "파일 내용을 검색하려면 호스트를 업데이트하세요",
|
||||
contentBack: "결과로 돌아가기",
|
||||
contentOpen: "열기",
|
||||
contentLoadingPreview: "미리보기 로딩 중...",
|
||||
contentLimited: "결과 제한 — 검색 범위를 좁히세요",
|
||||
contentSkippedLargeFiles: "{{size}}보다 큰 파일은 검색하지 않았습니다",
|
||||
contentChanged: "파일이 변경되어 이 위치에 일치하는 내용이 없습니다",
|
||||
|
||||
placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...",
|
||||
filePlaceholder: "파일 검색...",
|
||||
searchingFiles: "파일 검색 중...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ptBR: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "Buscar",
|
||||
placeholder: "Buscar no painel",
|
||||
close: "Fechar busca",
|
||||
matches: "Correspondências",
|
||||
previous: "Correspondência anterior",
|
||||
next: "Próxima correspondência",
|
||||
toggleReplace: "Mostrar substituição",
|
||||
replaceWith: "Substituir por",
|
||||
replace: "Substituir",
|
||||
replaceAll: "Substituir tudo",
|
||||
noMatches: "Nenhuma correspondência",
|
||||
position: "{{current}} de {{total}}",
|
||||
total: "{{total}} correspondências",
|
||||
},
|
||||
common: {
|
||||
back: "Voltar",
|
||||
loading: "Carregando...",
|
||||
@@ -58,6 +73,18 @@ export const ptBR: TranslationResources = {
|
||||
close: "Fechar menu",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Buscar conteúdo dos arquivos",
|
||||
content: "Conteúdo dos arquivos",
|
||||
contentPlaceholder: "Buscar conteúdo salvo...",
|
||||
contentUnavailable: "Espaço de trabalho indisponível",
|
||||
contentUpdateHost: "Atualize este host para buscar conteúdo",
|
||||
contentBack: "Voltar aos resultados",
|
||||
contentOpen: "Abrir",
|
||||
contentLoadingPreview: "Carregando prévia...",
|
||||
contentLimited: "Resultados limitados — refine a busca",
|
||||
contentSkippedLargeFiles: "Arquivos maiores que {{size}} não foram pesquisados",
|
||||
contentChanged: "Arquivo alterado — a correspondência não está mais neste local",
|
||||
|
||||
placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...",
|
||||
filePlaceholder: "Pesquisar arquivos...",
|
||||
searchingFiles: "Pesquisando arquivos...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ru: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "Найти",
|
||||
placeholder: "Найти в панели",
|
||||
close: "Закрыть поиск",
|
||||
matches: "Совпадения",
|
||||
previous: "Предыдущее совпадение",
|
||||
next: "Следующее совпадение",
|
||||
toggleReplace: "Показать замену",
|
||||
replaceWith: "Заменить на",
|
||||
replace: "Заменить",
|
||||
replaceAll: "Заменить всё",
|
||||
noMatches: "Нет совпадений",
|
||||
position: "{{current}} из {{total}}",
|
||||
total: "Совпадений: {{total}}",
|
||||
},
|
||||
common: {
|
||||
back: "Назад",
|
||||
loading: "Загрузка...",
|
||||
@@ -58,6 +73,18 @@ export const ru: TranslationResources = {
|
||||
close: "Закрыть меню",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Поиск по содержимому файлов",
|
||||
content: "Содержимое файлов",
|
||||
contentPlaceholder: "Поиск в сохранённых файлах...",
|
||||
contentUnavailable: "Рабочее пространство недоступно",
|
||||
contentUpdateHost: "Обновите этот хост для поиска по содержимому",
|
||||
contentBack: "Назад к результатам",
|
||||
contentOpen: "Открыть",
|
||||
contentLoadingPreview: "Загрузка предпросмотра...",
|
||||
contentLimited: "Результаты ограничены — уточните поиск",
|
||||
contentSkippedLargeFiles: "Файлы больше {{size}} не искались",
|
||||
contentChanged: "Файл изменён — совпадения больше нет в этом месте",
|
||||
|
||||
placeholder: "Поиск команд, файлов, рабочих пространств и агентов...",
|
||||
filePlaceholder: "Поиск файлов...",
|
||||
searchingFiles: "Поиск файлов...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const zhCN: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "查找",
|
||||
placeholder: "在窗格中查找",
|
||||
close: "关闭查找",
|
||||
matches: "查找结果",
|
||||
previous: "上一个匹配项",
|
||||
next: "下一个匹配项",
|
||||
toggleReplace: "切换替换",
|
||||
replaceWith: "替换为",
|
||||
replace: "替换",
|
||||
replaceAll: "全部替换",
|
||||
noMatches: "无匹配项",
|
||||
position: "{{current}} / {{total}}",
|
||||
total: "{{total}} 个匹配项",
|
||||
},
|
||||
common: {
|
||||
back: "返回",
|
||||
loading: "加载中...",
|
||||
@@ -58,6 +73,18 @@ export const zhCN: TranslationResources = {
|
||||
close: "关闭菜单",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "搜索文件内容",
|
||||
content: "文件内容",
|
||||
contentPlaceholder: "搜索已保存的文件内容...",
|
||||
contentUnavailable: "工作区不可用",
|
||||
contentUpdateHost: "请更新此主机以搜索文件内容",
|
||||
contentBack: "返回结果",
|
||||
contentOpen: "打开",
|
||||
contentLoadingPreview: "正在加载预览...",
|
||||
contentLimited: "结果已达上限 — 请缩小搜索范围",
|
||||
contentSkippedLargeFiles: "未搜索大于 {{size}} 的文件",
|
||||
contentChanged: "文件已更改 — 此位置不再匹配",
|
||||
|
||||
placeholder: "搜索命令、文件、工作区和 Agent...",
|
||||
filePlaceholder: "搜索文件...",
|
||||
searchingFiles: "正在搜索文件...",
|
||||
|
||||
@@ -46,6 +46,7 @@ export type KeyboardActionId =
|
||||
| "settings.toggle"
|
||||
| "command-center.toggle"
|
||||
| "command-center.files"
|
||||
| "command-center.content"
|
||||
| "shortcuts.dialog.toggle"
|
||||
| "workspace.terminal.new"
|
||||
| "workspace.new"
|
||||
|
||||
@@ -1196,3 +1196,20 @@ describe("direct new-tab target shortcuts", () => {
|
||||
).toEqual([["ctrl", "shift", "H"]]);
|
||||
});
|
||||
});
|
||||
|
||||
it("searches contents from terminal focus while preserving explicit Focus overrides", () => {
|
||||
const event = { key: "F", code: "KeyF", metaKey: true, shiftKey: true };
|
||||
const context = { isMac: true, isDesktop: true, focusScope: "terminal" as const };
|
||||
expect(resolveShortcut({ event, context }).match?.action).toBe("command-center.content");
|
||||
expect(resolveShortcut({ event: { ...event, altKey: true }, context }).match?.action).toBe(
|
||||
"view.toggle.focus",
|
||||
);
|
||||
const bindings = buildEffectiveBindings({ "view-toggle-focus-cmd-shift-f-mac": "Cmd+Shift+F" });
|
||||
expect(resolveShortcut({ event, context, bindings }).match?.action).toBe("view.toggle.focus");
|
||||
const disabled = buildEffectiveBindings({
|
||||
"view-toggle-focus-cmd-shift-f-mac": UNASSIGNED_COMBO,
|
||||
});
|
||||
expect(
|
||||
resolveShortcut({ event: { ...event, altKey: true }, context, bindings: disabled }).match,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -156,6 +156,7 @@ export const SHORTCUT_HELP_ROW_ORDER: Record<ShortcutSectionId, readonly string[
|
||||
general: [
|
||||
"toggle-command-center",
|
||||
"search-files",
|
||||
"search-workspace-content",
|
||||
"show-shortcuts",
|
||||
"toggle-settings",
|
||||
"cycle-theme",
|
||||
@@ -239,6 +240,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||
"toggle-right-sidebar": "settings.shortcuts.help.toggleRightSidebar",
|
||||
"toggle-both-sidebars": "settings.shortcuts.help.toggleBothSidebars",
|
||||
"toggle-settings": "settings.shortcuts.help.toggleSettings",
|
||||
"search-workspace-content": "shell.commandCenter.contentTitle",
|
||||
"toggle-focus": "settings.shortcuts.help.toggleFocusMode",
|
||||
"cycle-theme": "settings.shortcuts.help.cycleTheme",
|
||||
"focus-message-input": "settings.shortcuts.help.focusMessageInput",
|
||||
@@ -1003,7 +1005,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "view-toggle-focus-cmd-shift-f-mac",
|
||||
action: "view.toggle.focus",
|
||||
combo: "Cmd+Shift+F",
|
||||
combo: "Cmd+Alt+Shift+F",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "toggle-focus",
|
||||
@@ -1014,7 +1016,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "view-toggle-focus-ctrl-shift-f-non-mac",
|
||||
action: "view.toggle.focus",
|
||||
combo: "Ctrl+Shift+F",
|
||||
combo: "Ctrl+Alt+Shift+F",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "toggle-focus",
|
||||
@@ -1023,6 +1025,21 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "command-center-content-cmd-shift-f-mac",
|
||||
action: "command-center.content",
|
||||
combo: "Cmd+Shift+F",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: { id: "search-workspace-content", section: "general", label: "Search file contents" },
|
||||
},
|
||||
{
|
||||
id: "command-center-content-ctrl-shift-f-non-mac",
|
||||
action: "command-center.content",
|
||||
combo: "Ctrl+Shift+F",
|
||||
when: { mac: false, commandCenter: false },
|
||||
help: { id: "search-workspace-content", section: "general", label: "Search file contents" },
|
||||
},
|
||||
|
||||
// --- Theme cycling ---
|
||||
{
|
||||
id: "theme-cycle-cmd-shift-t-mac",
|
||||
|
||||
@@ -32,7 +32,7 @@ export type ShortcutAction =
|
||||
| { kind: "router-push"; route: string }
|
||||
| { kind: "open-project-picker" }
|
||||
| { kind: "callback"; name: ShortcutCallbackName }
|
||||
| { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" }
|
||||
| { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" | "content" }
|
||||
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
|
||||
|
||||
const NONE: ShortcutAction = { kind: "none" };
|
||||
@@ -209,6 +209,10 @@ export function routeKeyboardShortcut(
|
||||
return routeSettingsToggle(ctx);
|
||||
case "command-center.toggle":
|
||||
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
|
||||
case "command-center.content":
|
||||
if (parseHostWorkspaceRouteFromPathname(ctx.pathname))
|
||||
return { kind: "command-center-toggle", nextOpen: true, scope: "content" };
|
||||
return dispatch({ id: "workspace.project.pick", scope: "workspace" });
|
||||
case "command-center.files":
|
||||
if (parseHostWorkspaceRouteFromPathname(ctx.pathname)) {
|
||||
return { kind: "command-center-toggle", nextOpen: true, scope: "files" };
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type NativeSyntheticEvent,
|
||||
type TextInputKeyPressEventData,
|
||||
type TextInputProps,
|
||||
} from "react-native";
|
||||
import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import {
|
||||
createControlGeometry,
|
||||
resolveControlInteractionStyles,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import {
|
||||
mutedIconColorMapping,
|
||||
smallIconButtonChromeFrameSize,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
|
||||
const TextInput = withUnistyles(EditingTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ArrowUpIcon = withUnistyles(ArrowUp, mutedIconColorMapping);
|
||||
const ArrowDownIcon = withUnistyles(ArrowDown, mutedIconColorMapping);
|
||||
const CloseIcon = withUnistyles(X, mutedIconColorMapping);
|
||||
const ChevronDownIcon = withUnistyles(ChevronDown, mutedIconColorMapping);
|
||||
const ChevronRightIcon = withUnistyles(ChevronRight, mutedIconColorMapping);
|
||||
|
||||
export interface PaneFindHandle {
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
export interface PaneFindProps {
|
||||
query: string;
|
||||
status: string;
|
||||
canNavigate: boolean;
|
||||
onQueryChange(query: string): void;
|
||||
onNext(): void;
|
||||
onPrevious(): void;
|
||||
onClose(): void;
|
||||
replace?: {
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
onReplace(): void;
|
||||
onReplaceAll(): void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bordered box around one Find input. It owns the field chrome so the query
|
||||
* row and the replacement row land on the same rails, and so the match count can
|
||||
* sit inside the query box instead of widening the widget.
|
||||
*/
|
||||
const FindField = forwardRef<
|
||||
EditingTextInputHandle,
|
||||
{
|
||||
label: string;
|
||||
initialValue: string;
|
||||
height: number;
|
||||
trailing?: ReactNode;
|
||||
onChangeText(value: string): void;
|
||||
onKeyPress(event: NativeSyntheticEvent<TextInputKeyPressEventData>): void;
|
||||
autoFocus?: boolean;
|
||||
returnKeyType?: TextInputProps["returnKeyType"];
|
||||
}
|
||||
>(function FindField(
|
||||
{ label, initialValue, height, trailing, onChangeText, onKeyPress, autoFocus, returnKeyType },
|
||||
ref,
|
||||
) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const onFocus = useCallback(() => setFocused(true), []);
|
||||
const onBlur = useCallback(() => setFocused(false), []);
|
||||
const fieldStyle = useMemo(
|
||||
() => [
|
||||
styles.field,
|
||||
{ minHeight: height },
|
||||
resolveControlInteractionStyles(
|
||||
{
|
||||
controlRest: styles.controlRest,
|
||||
controlHover: styles.controlHover,
|
||||
controlActive: styles.controlActive,
|
||||
},
|
||||
{ focused },
|
||||
),
|
||||
],
|
||||
[focused, height],
|
||||
);
|
||||
return (
|
||||
<View style={fieldStyle}>
|
||||
<TextInput
|
||||
ref={ref}
|
||||
autoFocus={autoFocus}
|
||||
selectTextOnFocus
|
||||
initialValue={initialValue}
|
||||
onChangeText={onChangeText}
|
||||
onKeyPress={onKeyPress}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
accessibilityLabel={label}
|
||||
placeholder={label}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
blurOnSubmit={false}
|
||||
returnKeyType={returnKeyType}
|
||||
style={styles.input}
|
||||
/>
|
||||
{trailing}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
/** Pane-local chrome. The content owner supplies search state and commands. */
|
||||
export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneFind(
|
||||
{ query, status, canNavigate, onQueryChange, onNext, onPrevious, onClose, replace },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const controlSize = smallIconButtonChromeFrameSize(isCompact);
|
||||
const glyphSize = paneContentToolbarIconSize(isCompact);
|
||||
const rowHeight = Math.max(COLLAPSED_FIELD_HEIGHT, controlSize);
|
||||
// Keep the replace actions on the same row height as the icon controls beside them.
|
||||
const actionSize = isCompact ? "sm" : "xs";
|
||||
const input = useRef<EditingTextInputHandle>(null);
|
||||
const replacementInput = useRef<EditingTextInputHandle>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (input.current?.getText() !== query) input.current?.replaceText(query);
|
||||
}, [query]);
|
||||
useLayoutEffect(() => {
|
||||
if (replace && replacementInput.current?.getText() !== replace.value)
|
||||
replacementInput.current?.replaceText(replace.value);
|
||||
}, [replace]);
|
||||
const [replaceExpanded, setReplaceExpanded] = useState(false);
|
||||
const focus = useCallback(() => {
|
||||
input.current?.focus();
|
||||
const text = input.current?.getText() ?? "";
|
||||
input.current?.replaceText(text, { start: 0, end: text.length });
|
||||
}, []);
|
||||
useImperativeHandle(ref, () => ({ focus }), [focus]);
|
||||
|
||||
const onKeyPress = useCallback(
|
||||
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
const key = event.nativeEvent as TextInputKeyPressEventData & {
|
||||
shiftKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
};
|
||||
if (isImeComposingKeyboardEvent(key)) return;
|
||||
if (
|
||||
(key.metaKey || key.ctrlKey) &&
|
||||
!key.altKey &&
|
||||
!key.shiftKey &&
|
||||
key.key.toLowerCase() === "f"
|
||||
) {
|
||||
// RN Web inputs stop keydown before the pane's document listener.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
focus();
|
||||
} else if (key.key === "Escape" || key.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (key.key === "Escape") onClose();
|
||||
else if (key.shiftKey) onPrevious();
|
||||
else onNext();
|
||||
}
|
||||
},
|
||||
[focus, onClose, onNext, onPrevious],
|
||||
);
|
||||
const toggleReplace = useCallback(() => setReplaceExpanded((expanded) => !expanded), []);
|
||||
const gutterStyle = useMemo(
|
||||
() => [styles.gutter, { width: controlSize, height: rowHeight }],
|
||||
[controlSize, rowHeight],
|
||||
);
|
||||
const matchCount = useMemo(
|
||||
() => (
|
||||
<Text
|
||||
style={styles.status}
|
||||
role="status"
|
||||
accessibilityLabel={t("paneFind.matches")}
|
||||
accessibilityLiveRegion="polite"
|
||||
>
|
||||
{status}
|
||||
</Text>
|
||||
),
|
||||
[status, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.widget}
|
||||
accessibilityLabel={t("paneFind.title")}
|
||||
{...(isWeb
|
||||
? {
|
||||
onKeyDown: (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !isImeComposingKeyboardEvent(event.nativeEvent)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{replace ? (
|
||||
<View style={gutterStyle}>
|
||||
<ToolbarButton
|
||||
label={t("paneFind.toggleReplace")}
|
||||
aria-expanded={replaceExpanded}
|
||||
compact={isCompact}
|
||||
onPress={toggleReplace}
|
||||
>
|
||||
{replaceExpanded ? (
|
||||
<ChevronDownIcon size={glyphSize} />
|
||||
) : (
|
||||
<ChevronRightIcon size={glyphSize} />
|
||||
)}
|
||||
</ToolbarButton>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.rows}>
|
||||
<View style={styles.row}>
|
||||
<FindField
|
||||
ref={input}
|
||||
autoFocus
|
||||
label={t("paneFind.placeholder")}
|
||||
initialValue={query}
|
||||
height={rowHeight}
|
||||
returnKeyType="search"
|
||||
trailing={matchCount}
|
||||
onChangeText={onQueryChange}
|
||||
onKeyPress={onKeyPress}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label={t("paneFind.previous")}
|
||||
compact={isCompact}
|
||||
disabled={!canNavigate}
|
||||
onPress={onPrevious}
|
||||
>
|
||||
<ArrowUpIcon size={glyphSize} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t("paneFind.next")}
|
||||
compact={isCompact}
|
||||
disabled={!canNavigate}
|
||||
onPress={onNext}
|
||||
>
|
||||
<ArrowDownIcon size={glyphSize} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label={t("paneFind.close")} compact={isCompact} onPress={onClose}>
|
||||
<CloseIcon size={glyphSize} />
|
||||
</ToolbarButton>
|
||||
</View>
|
||||
{replace && replaceExpanded ? (
|
||||
<View style={styles.row}>
|
||||
<FindField
|
||||
ref={replacementInput}
|
||||
label={t("paneFind.replaceWith")}
|
||||
initialValue={replace.value}
|
||||
height={rowHeight}
|
||||
onChangeText={replace.onChange}
|
||||
onKeyPress={onKeyPress}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={actionSize}
|
||||
style={styles.replaceAction}
|
||||
disabled={!canNavigate}
|
||||
onPress={replace.onReplace}
|
||||
>
|
||||
{t("paneFind.replace")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={actionSize}
|
||||
style={styles.replaceAction}
|
||||
disabled={!canNavigate}
|
||||
onPress={replace.onReplaceAll}
|
||||
>
|
||||
{t("paneFind.replaceAll")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
/** Desktop find rows run one step tighter than a form field — this is pane chrome. */
|
||||
const COLLAPSED_FIELD_HEIGHT = 28;
|
||||
const FIND_WIDGET_WIDTH = 340;
|
||||
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
return {
|
||||
widget: {
|
||||
width: FIND_WIDGET_WIDTH,
|
||||
maxWidth: "100%",
|
||||
flexDirection: "row",
|
||||
padding: theme.spacing[1.5],
|
||||
gap: theme.spacing[1],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
// The disclosure column spans both rows so the two fields share a leading rail.
|
||||
gutter: { alignItems: "center", justifyContent: "center", flexShrink: 0 },
|
||||
rows: { flex: 1, minWidth: 0, gap: theme.spacing[1] },
|
||||
row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1] },
|
||||
field: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
controlRest: { ...geometry.controlRest },
|
||||
controlHover: { ...geometry.controlHover },
|
||||
controlActive: { ...geometry.controlActive },
|
||||
input: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
color: theme.colors.foreground,
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
...geometry.fieldTextSm,
|
||||
},
|
||||
status: {
|
||||
flexShrink: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
// Ghost actions sit on the field's rail, so they carry the field's padding.
|
||||
replaceAction: { paddingHorizontal: theme.spacing[2] },
|
||||
};
|
||||
});
|
||||
@@ -75,7 +75,12 @@ export function usePaneContext(): PaneContextValue {
|
||||
}
|
||||
|
||||
export function usePaneFocus(): PaneFocusContextValue {
|
||||
const value = useContext(PaneFocusContext);
|
||||
const value = usePaneFocusIfAny();
|
||||
invariant(value, "PaneFocusContext is required");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Pane focus for subtrees that a pane may or may not own, such as an overlay preview. */
|
||||
export function usePaneFocusIfAny(): PaneFocusContextValue | null {
|
||||
return useContext(PaneFocusContext);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
|
||||
function createInput(closeExplorerAfterOpen: boolean) {
|
||||
return {
|
||||
filePath: "src/app.tsx",
|
||||
location: { path: "src/app.tsx" } as WorkspaceFileLocation,
|
||||
persistenceKey: "server:workspace",
|
||||
closeExplorerAfterOpen,
|
||||
showMobileAgent: vi.fn(),
|
||||
openWorkspaceTabInFocusedPane: vi.fn(() => "file-tab"),
|
||||
focusWorkspaceTab: vi.fn(),
|
||||
requestFileNavigation: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("openWorkspaceFileFromExplorer", () => {
|
||||
describe("openWorkspaceFileInFocusedPane", () => {
|
||||
it("closes the phone overlay after opening a file", () => {
|
||||
const input = createInput(true);
|
||||
|
||||
openWorkspaceFileFromExplorer(input);
|
||||
openWorkspaceFileInFocusedPane(input);
|
||||
|
||||
expect(input.showMobileAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -24,10 +26,29 @@ describe("openWorkspaceFileFromExplorer", () => {
|
||||
it("keeps the tablet dock open after opening a file", () => {
|
||||
const input = createInput(false);
|
||||
|
||||
openWorkspaceFileFromExplorer(input);
|
||||
openWorkspaceFileInFocusedPane(input);
|
||||
|
||||
expect(input.showMobileAgent).not.toHaveBeenCalled();
|
||||
expect(input.openWorkspaceTabInFocusedPane).toHaveBeenCalledOnce();
|
||||
expect(input.focusWorkspaceTab).toHaveBeenCalledWith("server:workspace", "file-tab");
|
||||
});
|
||||
|
||||
it("asks for navigation every time, so reopening the same occurrence recentres its pane", () => {
|
||||
const input = createInput(false);
|
||||
input.location = { path: "src/app.tsx", lineStart: 1201, columnStart: 17 };
|
||||
|
||||
openWorkspaceFileInFocusedPane(input);
|
||||
openWorkspaceFileInFocusedPane(input);
|
||||
|
||||
expect(input.requestFileNavigation).toHaveBeenCalledTimes(2);
|
||||
expect(input.requestFileNavigation).toHaveBeenLastCalledWith("file-tab");
|
||||
});
|
||||
|
||||
it("does not navigate when no tab could be opened", () => {
|
||||
const input = { ...createInput(false), openWorkspaceTabInFocusedPane: vi.fn(() => null) };
|
||||
|
||||
openWorkspaceFileInFocusedPane(input);
|
||||
|
||||
expect(input.requestFileNavigation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
type WorkspaceFileLocation,
|
||||
} from "@/workspace/file-open";
|
||||
import {
|
||||
FOCUSED_PANE_PLACEMENT,
|
||||
@@ -8,8 +9,8 @@ import {
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
interface OpenWorkspaceFileFromExplorerInput {
|
||||
filePath: string;
|
||||
interface OpenWorkspaceFileInput {
|
||||
location: WorkspaceFileLocation;
|
||||
persistenceKey: string | null;
|
||||
closeExplorerAfterOpen: boolean;
|
||||
showMobileAgent: () => void;
|
||||
@@ -19,16 +20,24 @@ interface OpenWorkspaceFileFromExplorerInput {
|
||||
placement?: WorkspaceTabPlacement,
|
||||
) => string | null;
|
||||
focusWorkspaceTab: (workspaceKey: string, tabId: string) => void;
|
||||
requestFileNavigation: (tabId: string) => void;
|
||||
}
|
||||
|
||||
export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplorerInput): void {
|
||||
/**
|
||||
* Opens a workspace file in the focused pane and navigates to it.
|
||||
*
|
||||
* The navigation request is the part callers keep forgetting: a file target is identity-stable, so
|
||||
* reopening the same path — or the same occurrence — reuses its tab and would otherwise leave the
|
||||
* pane wherever the reader had scrolled it. Every explicit activation asks for a reveal.
|
||||
*/
|
||||
export function openWorkspaceFileInFocusedPane(input: OpenWorkspaceFileInput): void {
|
||||
if (input.closeExplorerAfterOpen) {
|
||||
input.showMobileAgent();
|
||||
}
|
||||
if (!input.persistenceKey) {
|
||||
return;
|
||||
}
|
||||
const location = normalizeWorkspaceFileLocation({ path: input.filePath });
|
||||
const location = normalizeWorkspaceFileLocation(input.location);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
@@ -37,7 +46,9 @@ export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplor
|
||||
createWorkspaceFileTabTarget(location),
|
||||
FOCUSED_PANE_PLACEMENT,
|
||||
);
|
||||
if (tabId) {
|
||||
input.focusWorkspaceTab(input.persistenceKey, tabId);
|
||||
if (!tabId) {
|
||||
return;
|
||||
}
|
||||
input.focusWorkspaceTab(input.persistenceKey, tabId);
|
||||
input.requestFileNavigation(tabId);
|
||||
}
|
||||
|
||||
@@ -1647,17 +1647,13 @@ function WorkspaceScreenContent({
|
||||
) => openTab({ workspaceKey, target, intent: "reveal", parentTabId, placement }),
|
||||
[openTab],
|
||||
);
|
||||
// File targets stay identity-stable so the same path reuses its tab. Keep navigation
|
||||
// requests separate so clicking an unchanged path:line can still recenter the pane.
|
||||
const [fileNavigationRevisionByTabId, setFileNavigationRevisionByTabId] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const requestFileNavigation = useCallback((tabId: string) => {
|
||||
setFileNavigationRevisionByTabId((current) => ({
|
||||
...current,
|
||||
[tabId]: (current[tabId] ?? 0) + 1,
|
||||
}));
|
||||
}, []);
|
||||
// File targets stay identity-stable so the same path reuses its tab. Navigation requests live
|
||||
// with the tabs in the layout store, so an opener outside this screen — the Command Center —
|
||||
// asks for the same reveal through the same capability.
|
||||
const fileNavigationRevisionByTabId = useWorkspaceLayoutStore(
|
||||
(state) => state.fileNavigationRevisionByTabId,
|
||||
);
|
||||
const requestFileNavigation = useWorkspaceLayoutStore((state) => state.requestFileNavigation);
|
||||
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
|
||||
|
||||
const SHORTCUT_BADGE_DELAY_MS = 150;
|
||||
|
||||
export type CommandCenterScope = "files" | null;
|
||||
export type CommandCenterScope = "files" | "content" | null;
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
commandCenterOpen: boolean;
|
||||
|
||||
@@ -110,6 +110,12 @@ interface WorkspaceLayoutStore {
|
||||
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
||||
/**
|
||||
* Bumped whenever a file tab is explicitly activated. File targets are identity-stable so the
|
||||
* same path reuses its tab; without this an activation of an unchanged location would look like
|
||||
* a no-op and leave the pane wherever the reader had scrolled it.
|
||||
*/
|
||||
fileNavigationRevisionByTabId: Record<string, number>;
|
||||
explorerSidebarPaneIdByWorkspace: Record<string, string | null>;
|
||||
sidePaneIdByWorkspace: Record<string, string | null>;
|
||||
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
||||
@@ -120,6 +126,7 @@ interface WorkspaceLayoutStore {
|
||||
ensureSidePane: (workspaceKey: string) => string | null;
|
||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||
focusTab: (workspaceKey: string, tabId: string) => void;
|
||||
requestFileNavigation: (tabId: string) => void;
|
||||
selectTabInPane: (workspaceKey: string, paneId: string, tabId: string) => void;
|
||||
replaceTab: (
|
||||
workspaceKey: string,
|
||||
@@ -207,6 +214,9 @@ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
|
||||
path: z.string(),
|
||||
lineStart: z.number().int().positive().optional(),
|
||||
lineEnd: z.number().int().positive().optional(),
|
||||
columnStart: z.number().int().positive().optional(),
|
||||
columnEnd: z.number().int().positive().optional(),
|
||||
expectedText: z.string().optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("working_diff"),
|
||||
@@ -767,6 +777,7 @@ export function createWorkspaceLayoutStore(
|
||||
pendingAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
fileNavigationRevisionByTabId: {},
|
||||
explorerSidebarPaneIdByWorkspace: {},
|
||||
sidePaneIdByWorkspace: {},
|
||||
openTab: (input) => {
|
||||
@@ -1040,6 +1051,16 @@ export function createWorkspaceLayoutStore(
|
||||
};
|
||||
});
|
||||
},
|
||||
requestFileNavigation: (tabId) => {
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!normalizedTabId) return;
|
||||
set((state) => ({
|
||||
fileNavigationRevisionByTabId: {
|
||||
...state.fileNavigationRevisionByTabId,
|
||||
[normalizedTabId]: (state.fileNavigationRevisionByTabId[normalizedTabId] ?? 0) + 1,
|
||||
},
|
||||
}));
|
||||
},
|
||||
focusTab: (workspaceKey, tabId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Renders a byte count in the units the file pane and file search already show. */
|
||||
export function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -1,3 +1,16 @@
|
||||
export function isAbsolutePath(value: string): boolean {
|
||||
return value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites separators only for a path that is Windows-shaped, meaning it carries a drive letter
|
||||
* or a UNC prefix. Everywhere else a backslash is an ordinary file name character, so a POSIX or
|
||||
* workspace-relative path is returned untouched.
|
||||
*/
|
||||
export function normalizePathSeparators(value: string): string {
|
||||
return isWindowsShapedPath(value) ? value.replace(/\\/g, "/") : value;
|
||||
}
|
||||
|
||||
function isWindowsShapedPath(value: string): boolean {
|
||||
return value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveWorkspaceFileSelection,
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
resolveWorkspaceFilePaths,
|
||||
@@ -7,10 +8,10 @@ import {
|
||||
} from ".";
|
||||
|
||||
describe("normalizeWorkspaceFileLocation", () => {
|
||||
it("normalizes paths and valid line ranges", () => {
|
||||
it("clamps line ranges and leaves the path as the producer gave it", () => {
|
||||
expect(
|
||||
normalizeWorkspaceFileLocation({
|
||||
path: "src\\app.ts",
|
||||
path: "src/app.ts",
|
||||
lineStart: 12.8,
|
||||
lineEnd: 20.2,
|
||||
}),
|
||||
@@ -34,7 +35,7 @@ describe("normalizeWorkspaceFileLocation", () => {
|
||||
});
|
||||
|
||||
it("rejects empty paths", () => {
|
||||
expect(normalizeWorkspaceFileLocation({ path: " " })).toBeNull();
|
||||
expect(normalizeWorkspaceFileLocation({ path: "" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,13 +98,33 @@ describe("resolveWorkspaceFilePaths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes Windows separators in the file path", () => {
|
||||
it("normalizes separators in a Windows-shaped path and leaves every other path literal", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }),
|
||||
resolveWorkspaceFilePaths({
|
||||
path: "C:\\Users\\me\\repo\\src\\app.ts",
|
||||
workspaceRoot: "C:\\Users\\me\\repo",
|
||||
}),
|
||||
).toEqual({
|
||||
absolutePath: "/Users/me/repo/src/app.ts",
|
||||
absolutePath: "C:/Users/me/repo/src/app.ts",
|
||||
relativePath: "src/app.ts",
|
||||
});
|
||||
// A workspace-relative path is the host's own identity, so its backslash is a file name
|
||||
// character. Rewriting it here is what opened a different file than the search result.
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "a\\b.txt", workspaceRoot: "/Users/me/repo" }),
|
||||
).toEqual({
|
||||
absolutePath: "/Users/me/repo/a\\b.txt",
|
||||
relativePath: "a\\b.txt",
|
||||
});
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({
|
||||
path: "/Users/me/repo/a\\b.txt",
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
absolutePath: "/Users/me/repo/a\\b.txt",
|
||||
relativePath: "a\\b.txt",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes dot segments in workspace-relative paths", () => {
|
||||
@@ -183,3 +204,50 @@ describe("resolveWorkspaceFilePaths", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("selects exact UTF-16 columns and refuses a stale saved match", () => {
|
||||
const location = {
|
||||
path: "a.ts",
|
||||
lineStart: 2,
|
||||
columnStart: 5,
|
||||
columnEnd: 11,
|
||||
expectedText: "NEEDLE",
|
||||
};
|
||||
expect(resolveWorkspaceFileSelection("first\né🙂 NEEDLE", location)).toEqual({
|
||||
from: 10,
|
||||
to: 16,
|
||||
changed: false,
|
||||
});
|
||||
expect(resolveWorkspaceFileSelection("first\né🙂 change", location)).toEqual({
|
||||
from: 10,
|
||||
to: 10,
|
||||
changed: true,
|
||||
});
|
||||
expect(workspaceFileLocationsEqual(location, { ...location, columnStart: 6 })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not select an identical occurrence on another line after the saved line disappears", () => {
|
||||
expect(
|
||||
resolveWorkspaceFileSelection("needle", {
|
||||
path: "a.ts",
|
||||
lineStart: 3,
|
||||
columnStart: 1,
|
||||
columnEnd: 7,
|
||||
expectedText: "needle",
|
||||
}),
|
||||
).toEqual({ from: 0, to: 0, changed: true });
|
||||
});
|
||||
|
||||
it("preserves literal filename and workspace whitespace through location and host paths", () => {
|
||||
const location = { path: " leading.txt ", lineStart: 1 };
|
||||
expect(normalizeWorkspaceFileLocation(location)).toEqual(location);
|
||||
expect(resolveWorkspaceFilePaths({ path: location.path, workspaceRoot: "/workspace " })).toEqual({
|
||||
absolutePath: "/workspace / leading.txt ",
|
||||
relativePath: " leading.txt ",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a literal backslash file name through location identity", () => {
|
||||
const location = { path: "a\\b.txt", lineStart: 1, columnStart: 1, expectedText: "needle" };
|
||||
expect(normalizeWorkspaceFileLocation(location)).toEqual(location);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { isAbsolutePath, normalizePathSeparators } from "@/utils/path";
|
||||
|
||||
export type OpenFileDisposition = "main" | "preferred" | "side";
|
||||
|
||||
export interface WorkspaceFileLocation {
|
||||
path: string;
|
||||
/** One-based source line; CRLF, CR and LF each separate lines. */
|
||||
lineStart?: number;
|
||||
lineEnd?: number;
|
||||
/** One-based UTF-16 column on lineStart. */
|
||||
columnStart?: number;
|
||||
/** Exclusive UTF-16 column on the same line. */
|
||||
columnEnd?: number;
|
||||
/** Original saved occurrence; a mismatch reveals the location without selecting text. */
|
||||
expectedText?: string;
|
||||
}
|
||||
|
||||
export type WorkspaceFileTabTarget = { kind: "file" } & WorkspaceFileLocation;
|
||||
@@ -22,16 +29,25 @@ export function normalizeWorkspaceFileLocation(
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = location.path.trim().replace(/\\/g, "/");
|
||||
// The path is an identity the host already produced, so it is kept verbatim: a backslash is a
|
||||
// file name character outside Windows, and rewriting it here opened a different file than the
|
||||
// one a search result or explorer row named. Callers holding a path scraped out of text
|
||||
// normalize it themselves before arriving.
|
||||
const path = location.path;
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineStart = normalizeLineNumber(location.lineStart);
|
||||
const lineEnd = normalizeLineNumber(location.lineEnd);
|
||||
const columnStart = normalizeLineNumber(location.columnStart);
|
||||
const columnEnd = normalizeLineNumber(location.columnEnd);
|
||||
return {
|
||||
path,
|
||||
...(lineStart ? { lineStart } : {}),
|
||||
...(lineStart && columnStart ? { columnStart } : {}),
|
||||
...(lineStart && columnEnd && columnEnd >= (columnStart ?? 1) ? { columnEnd } : {}),
|
||||
...(location.expectedText !== undefined ? { expectedText: location.expectedText } : {}),
|
||||
...(lineStart && lineEnd && lineEnd >= lineStart ? { lineEnd } : {}),
|
||||
};
|
||||
}
|
||||
@@ -41,7 +57,12 @@ export function workspaceFileLocationsEqual(
|
||||
right: WorkspaceFileLocation,
|
||||
): boolean {
|
||||
return (
|
||||
left.path === right.path && left.lineStart === right.lineStart && left.lineEnd === right.lineEnd
|
||||
left.path === right.path &&
|
||||
left.lineStart === right.lineStart &&
|
||||
left.lineEnd === right.lineEnd &&
|
||||
left.columnStart === right.columnStart &&
|
||||
left.columnEnd === right.columnEnd &&
|
||||
left.expectedText === right.expectedText
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,8 +166,8 @@ export function resolveWorkspaceFilePaths(input: {
|
||||
path: string;
|
||||
workspaceRoot: string;
|
||||
}): ResolvedWorkspaceFilePaths | null {
|
||||
const filePath = input.path.trim().replace(/\\/g, "/");
|
||||
const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
|
||||
const filePath = normalizePathSeparators(input.path);
|
||||
const workspaceRoot = normalizeAbsolutePath(normalizePathSeparators(input.workspaceRoot));
|
||||
if (!filePath || !workspaceRoot) {
|
||||
return null;
|
||||
}
|
||||
@@ -176,3 +197,26 @@ export function resolveWorkspaceFilePaths(input: {
|
||||
}
|
||||
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
|
||||
}
|
||||
|
||||
/** Resolves a saved location without manufacturing a selection when its text has moved. */
|
||||
export function resolveWorkspaceFileSelection(
|
||||
content: string,
|
||||
location: WorkspaceFileLocation,
|
||||
): { from: number; to: number; changed: boolean } {
|
||||
const lines = content.split("\n");
|
||||
const index = Math.max(0, Math.min((location.lineStart ?? 1) - 1, lines.length - 1));
|
||||
const line = lines[index];
|
||||
const offset = lines.slice(0, index).reduce((sum, value) => sum + value.length + 1, 0);
|
||||
const from = offset + Math.min((location.columnStart ?? 1) - 1, line.length);
|
||||
const endIndex = Math.max(index, Math.min((location.lineEnd ?? index + 1) - 1, lines.length - 1));
|
||||
let to = from;
|
||||
if (location.columnEnd !== undefined) to = offset + Math.min(location.columnEnd - 1, line.length);
|
||||
else if (endIndex > index)
|
||||
to =
|
||||
lines.slice(0, endIndex).reduce((sum, value) => sum + value.length + 1, 0) +
|
||||
lines[endIndex].length;
|
||||
const changed =
|
||||
location.expectedText !== undefined &&
|
||||
(index !== (location.lineStart ?? 1) - 1 || content.slice(from, to) !== location.expectedText);
|
||||
return { from, to: changed ? from : to, changed };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WorkspaceContentSearchResult } from "@getpaseo/protocol/messages";
|
||||
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
@@ -4491,6 +4492,33 @@ export class DaemonClient {
|
||||
return payload.directory;
|
||||
}
|
||||
|
||||
async searchWorkspaceContent(
|
||||
input: { cwd: string; query: string },
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<WorkspaceContentSearchResult> {
|
||||
if (options.signal?.aborted)
|
||||
return { status: "error", code: "cancelled", message: "Search cancelled" };
|
||||
const requestId = this.createRequestId();
|
||||
const cancel = () => {
|
||||
void this.sendCorrelatedSessionRequest({
|
||||
message: { type: "fs.content.cancel.request", searchRequestId: requestId },
|
||||
responseType: "fs.content.cancel.response",
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
const response = this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: { type: "fs.content.search.request", ...input },
|
||||
responseType: "fs.content.search.response",
|
||||
});
|
||||
options.signal?.addEventListener("abort", cancel, { once: true });
|
||||
if (options.signal?.aborted) cancel();
|
||||
try {
|
||||
return (await response).result;
|
||||
} finally {
|
||||
options.signal?.removeEventListener("abort", cancel);
|
||||
}
|
||||
}
|
||||
|
||||
async readFile(
|
||||
cwd: string,
|
||||
path: string,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export type { FileReadResult } from "./daemon-client.js";
|
||||
import type { DaemonClientConfig } from "./daemon-client.js";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
|
||||
@@ -1075,38 +1075,8 @@ export const WorkspaceRecoveryInspectRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
const AgentRequestOperationSchema = z.object({
|
||||
key: z.string().min(1).max(512),
|
||||
deadlineAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export const AgentRequestsCancelRequestSchema = z.object({
|
||||
type: z.literal("agent.requests.cancel.request"),
|
||||
requestId: z.string(),
|
||||
key: z.string().min(1).max(512),
|
||||
creationKey: z.string().min(1).max(512),
|
||||
});
|
||||
|
||||
export const AgentRequestsInspectRequestSchema = AgentRequestsCancelRequestSchema.extend({
|
||||
type: z.literal("agent.requests.inspect.request"),
|
||||
});
|
||||
|
||||
export const AgentRequestsCancelResponseSchema = z.object({
|
||||
type: z.literal("agent.requests.cancel.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string().nullable(),
|
||||
outcome: z.enum(["settled", "pending", "unknown"]),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AgentRequestsInspectResponseSchema = AgentRequestsCancelResponseSchema.extend({
|
||||
type: z.literal("agent.requests.inspect.response"),
|
||||
});
|
||||
|
||||
export const WorkspaceRecoveryRestoreRequestSchema = z.object({
|
||||
type: z.literal("workspace.recovery.restore.request"),
|
||||
operation: AgentRequestOperationSchema.optional(),
|
||||
workspaceId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
@@ -1424,7 +1394,6 @@ export const FetchAgentRequestMessageSchema = z.object({
|
||||
|
||||
export const SendAgentMessageRequestSchema = z.object({
|
||||
type: z.literal("send_agent_message_request"),
|
||||
operation: AgentRequestOperationSchema.optional(),
|
||||
requestId: z.string(),
|
||||
/** Accepts full ID, unique prefix, or exact full title (server resolves). */
|
||||
agentId: z.string(),
|
||||
@@ -1711,7 +1680,6 @@ export type CreateAgentWorktreeTarget = z.infer<typeof CreateAgentWorktreeTarget
|
||||
|
||||
export const CreateAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("create_agent_request"),
|
||||
operation: AgentRequestOperationSchema.optional(),
|
||||
// A creation key requires initialPrompt to be sent separately with a stable messageId.
|
||||
idempotencyKey: z.string().min(1).max(512).optional(),
|
||||
config: AgentSessionConfigSchema,
|
||||
@@ -1805,7 +1773,6 @@ export const RefreshAgentRequestMessageSchema = z.object({
|
||||
|
||||
export const CancelAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("cancel_agent_request"),
|
||||
operation: AgentRequestOperationSchema.optional(),
|
||||
agentId: z.string(),
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
@@ -2578,7 +2545,6 @@ export const ProjectGithubCloneRequestSchema = z.object({
|
||||
|
||||
export const ArchiveWorkspaceRequestSchema = z.object({
|
||||
type: z.literal("archive_workspace_request"),
|
||||
operation: AgentRequestOperationSchema.optional(),
|
||||
workspaceId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
@@ -2722,6 +2688,57 @@ export const FileVersionSchema = z.discriminatedUnion("status", [
|
||||
}),
|
||||
]);
|
||||
|
||||
export const WorkspaceContentMatchSchema = z.object({
|
||||
path: z.string(),
|
||||
line: z.number().int().positive(),
|
||||
columnStart: z.number().int().positive(),
|
||||
columnEnd: z.number().int().positive(),
|
||||
text: z.string(),
|
||||
snippet: z.string(),
|
||||
snippetMatchStart: z.number().int().nonnegative(),
|
||||
snippetMatchEnd: z.number().int().nonnegative(),
|
||||
});
|
||||
export const WorkspaceContentSearchResultSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("ok"),
|
||||
matches: z.array(WorkspaceContentMatchSchema),
|
||||
limited: z.boolean(),
|
||||
/**
|
||||
* Per-file byte ceiling the host applied. Files above it are never opened, so a result set
|
||||
* is complete only for files at or under this size. Absent on hosts that do not report it.
|
||||
*/
|
||||
// COMPAT(workspaceContentSearchMaxFileBytes): added in v0.8.0, remove optionality after
|
||||
// 2027-03-10 once the daemon floor reports it.
|
||||
maxFileBytes: z.number().int().positive().optional(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("error"),
|
||||
code: z.enum(["missing_rg", "timeout", "invalid_query", "unavailable", "busy", "cancelled"]),
|
||||
message: z.string(),
|
||||
}),
|
||||
]);
|
||||
export const WorkspaceContentSearchRequestSchema = z.object({
|
||||
type: z.literal("fs.content.search.request"),
|
||||
requestId: z.string(),
|
||||
cwd: z.string(),
|
||||
query: z.string(),
|
||||
});
|
||||
export const WorkspaceContentCancelRequestSchema = z.object({
|
||||
type: z.literal("fs.content.cancel.request"),
|
||||
requestId: z.string(),
|
||||
searchRequestId: z.string(),
|
||||
});
|
||||
export const WorkspaceContentSearchResponseSchema = z.object({
|
||||
type: z.literal("fs.content.search.response"),
|
||||
payload: z.object({ requestId: z.string(), result: WorkspaceContentSearchResultSchema }),
|
||||
});
|
||||
export const WorkspaceContentCancelResponseSchema = z.object({
|
||||
type: z.literal("fs.content.cancel.response"),
|
||||
payload: z.object({ requestId: z.string(), searchRequestId: z.string() }),
|
||||
});
|
||||
export type WorkspaceContentMatch = z.infer<typeof WorkspaceContentMatchSchema>;
|
||||
export type WorkspaceContentSearchResult = z.infer<typeof WorkspaceContentSearchResultSchema>;
|
||||
|
||||
export const FileSubscribeRequestSchema = z.object({
|
||||
type: z.literal("fs.file.subscribe.request"),
|
||||
cwd: z.string(),
|
||||
@@ -3011,7 +3028,6 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
|
||||
type: z.literal("hub.execution.agent.create.request"),
|
||||
requestId: z.string(),
|
||||
executionId: z.string(),
|
||||
deadlineAt: z.string().datetime().optional(),
|
||||
provider: z.string(),
|
||||
cwd: z.string(),
|
||||
prompt: z.string(),
|
||||
@@ -3131,8 +3147,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
WorkspaceLabelDeleteInspectRequestSchema,
|
||||
WorkspaceRecoveryInspectRequestSchema,
|
||||
WorkspaceRecoveryRestoreRequestSchema,
|
||||
AgentRequestsCancelRequestSchema,
|
||||
AgentRequestsInspectRequestSchema,
|
||||
SetVoiceModeMessageSchema,
|
||||
SendAgentMessageRequestSchema,
|
||||
WaitForFinishRequestSchema,
|
||||
@@ -3248,6 +3262,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
WorkspaceClearAttentionRequestSchema,
|
||||
WorkspaceMarkUnreadRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
WorkspaceContentSearchRequestSchema,
|
||||
WorkspaceContentCancelRequestSchema,
|
||||
FileSubscribeRequestSchema,
|
||||
FileUnsubscribeRequestSchema,
|
||||
FileWriteRequestSchema,
|
||||
@@ -3470,7 +3486,6 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
.object({
|
||||
// COMPAT(agentRequestReceipts): added in v0.8.0; remove gate after 2027-03-05.
|
||||
agentRequestReceipts: z.boolean().optional(),
|
||||
agentRequestCancellation: z.boolean().optional(),
|
||||
// COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05.
|
||||
hubAgentRpc: z.boolean().optional(),
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
@@ -3554,6 +3569,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
workspaceRecovery: z.boolean().optional(),
|
||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||
workspaceFileEditing: z.boolean().optional(),
|
||||
workspaceContentSearch: z.boolean().optional(),
|
||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||
providerUsageList: z.boolean().optional(),
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
@@ -6581,8 +6597,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentAttentionRequiredMessageSchema,
|
||||
AgentForkContextResponseMessageSchema,
|
||||
CancelAgentResponseMessageSchema,
|
||||
AgentRequestsCancelResponseSchema,
|
||||
AgentRequestsInspectResponseSchema,
|
||||
ClearAgentAttentionResponseMessageSchema,
|
||||
WorkspaceCreateResponseSchema,
|
||||
WorkspaceClearAttentionResponseSchema,
|
||||
@@ -6657,6 +6671,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoWorktreeArchiveResponseSchema,
|
||||
CreatePaseoWorktreeResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
WorkspaceContentSearchResponseSchema,
|
||||
WorkspaceContentCancelResponseSchema,
|
||||
FileSubscribeResponseSchema,
|
||||
FileUnsubscribeResponseSchema,
|
||||
FileWriteResponseSchema,
|
||||
|
||||
@@ -1477,41 +1477,6 @@ function fakeCodexEmitting(args: FakeCodexEmitterArgs): AgentClient {
|
||||
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("canceling provider startup settles creation and closes a late session without registering it", async () => {
|
||||
const client = new HeldAgentCreationClient();
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger });
|
||||
const controller = new AbortController();
|
||||
let outcome = "pending";
|
||||
const creation = manager
|
||||
.createAgent({ provider: "codex", cwd: process.cwd() }, undefined, {
|
||||
workspaceId: undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
outcome = "created";
|
||||
return;
|
||||
},
|
||||
() => {
|
||||
outcome = "canceled";
|
||||
},
|
||||
);
|
||||
await client.waitForCreationToStart();
|
||||
try {
|
||||
controller.abort(new Error("startup expired"));
|
||||
await creation;
|
||||
expect(outcome).toBe("canceled");
|
||||
client.finishCreating();
|
||||
await expect.poll(() => client.createdSessionClosed).toBe(true);
|
||||
expect(manager.listAgents()).toEqual([]);
|
||||
} finally {
|
||||
client.finishCreating();
|
||||
await creation;
|
||||
manager.prepareForShutdown();
|
||||
await manager.flushForShutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not register a session that finishes starting after shutdown begins", async () => {
|
||||
const client = new HeldAgentCreationClient();
|
||||
const manager = new AgentManager({
|
||||
|
||||
@@ -278,7 +278,6 @@ type ProviderEnabledMap = Partial<Record<AgentProvider, ProviderEnabledFlag>>;
|
||||
type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
signal?: AbortSignal;
|
||||
labels?: Record<string, string>;
|
||||
initialPrompt?: string;
|
||||
env?: Record<string, string>;
|
||||
@@ -1228,16 +1227,8 @@ export class AgentManager {
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
const createOptions = this.buildCreateSessionOptions(options);
|
||||
options.signal?.throwIfAborted();
|
||||
const session = await this.waitForCreatedSession(
|
||||
client.createSession(providerLaunchConfig, launchContext, createOptions),
|
||||
options.signal,
|
||||
);
|
||||
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
|
||||
await this.requireExternalMcpSupport(session, storedConfig);
|
||||
if (options.signal?.aborted) {
|
||||
await this.closeUnregisteredSession(session);
|
||||
options.signal.throwIfAborted();
|
||||
}
|
||||
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
labels: options.labels,
|
||||
initialTitle: options.initialTitle,
|
||||
@@ -1253,38 +1244,6 @@ export class AgentManager {
|
||||
return agent;
|
||||
}
|
||||
|
||||
private async waitForCreatedSession(
|
||||
operation: Promise<AgentSession>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentSession> {
|
||||
if (!signal) return operation;
|
||||
return new Promise<AgentSession>((accept, reject) => {
|
||||
const abort = () => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
void operation
|
||||
.then(
|
||||
async (session) => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
if (signal.aborted) {
|
||||
await this.closeUnregisteredSession(session);
|
||||
} else accept(session);
|
||||
return;
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
reject(error);
|
||||
},
|
||||
)
|
||||
.catch((error: unknown) =>
|
||||
this.logger.error({ err: error }, "Failed to close canceled provider creation"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private buildCreateSessionOptions(options?: {
|
||||
persistSession?: boolean;
|
||||
}): AgentCreateSessionOptions | undefined {
|
||||
|
||||
@@ -182,7 +182,6 @@ export function isSystemInjectedEnvelope(text: string): boolean {
|
||||
}
|
||||
|
||||
export interface SendPromptToAgentParams {
|
||||
signal?: AbortSignal;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentId: string;
|
||||
@@ -205,7 +204,6 @@ export interface SendPromptToAgentParams {
|
||||
}
|
||||
|
||||
export interface StartCreatedAgentInitialPromptParams {
|
||||
signal?: AbortSignal;
|
||||
agentManager: AgentManager;
|
||||
agentId: string;
|
||||
snapshot?: ManagedAgent;
|
||||
@@ -231,7 +229,6 @@ const AGENT_RUN_START_TIMEOUT_MS = 60_000;
|
||||
export async function waitForAgentRunStartWithTimeout(
|
||||
agentManager: AgentManager,
|
||||
agentId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const provider = agentManager.getAgent(agentId)?.provider ?? "provider";
|
||||
const startAbort = new AbortController();
|
||||
@@ -246,9 +243,7 @@ export async function waitForAgentRunStartWithTimeout(
|
||||
);
|
||||
|
||||
try {
|
||||
await agentManager.waitForAgentRunStart(agentId, {
|
||||
signal: signal ? AbortSignal.any([signal, startAbort.signal]) : startAbort.signal,
|
||||
});
|
||||
await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal });
|
||||
} finally {
|
||||
clearTimeout(startTimeout);
|
||||
}
|
||||
@@ -268,7 +263,6 @@ export async function waitForAgentRunStartWithTimeout(
|
||||
export async function sendPromptToAgent(
|
||||
params: SendPromptToAgentParams,
|
||||
): Promise<{ disposition: PromptDispatchDisposition }> {
|
||||
params.signal?.throwIfAborted();
|
||||
const unarchive = params.unarchive ?? true;
|
||||
|
||||
const record = await params.agentStorage.get(params.agentId);
|
||||
@@ -289,7 +283,6 @@ export async function sendPromptToAgent(
|
||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
||||
}
|
||||
|
||||
params.signal?.throwIfAborted();
|
||||
const runOptions = params.messageId
|
||||
? { ...params.runOptions, clientMessageId: params.messageId }
|
||||
: params.runOptions;
|
||||
@@ -314,7 +307,6 @@ export async function startCreatedAgentInitialPrompt(
|
||||
return currentSnapshot;
|
||||
}
|
||||
|
||||
params.signal?.throwIfAborted();
|
||||
const dispatchResult = await startAgentRun(
|
||||
params.agentManager,
|
||||
params.agentId,
|
||||
@@ -326,7 +318,7 @@ export async function startCreatedAgentInitialPrompt(
|
||||
);
|
||||
|
||||
if (dispatchResult.disposition === "turn_started") {
|
||||
await waitForAgentRunStartWithTimeout(params.agentManager, params.agentId, params.signal);
|
||||
await waitForAgentRunStartWithTimeout(params.agentManager, params.agentId);
|
||||
}
|
||||
|
||||
const refreshedSnapshot = params.agentManager.getAgent(params.agentId) ?? params.snapshot ?? null;
|
||||
|
||||
@@ -55,7 +55,6 @@ export type EnsureWorkspaceForCreate = (
|
||||
|
||||
export interface CreateAgentFromSessionInput {
|
||||
kind: "session";
|
||||
signal?: AbortSignal;
|
||||
agentId?: string;
|
||||
config: AgentSessionConfig;
|
||||
workspaceId: string;
|
||||
@@ -80,7 +79,6 @@ export interface CreateAgentFromSessionInput {
|
||||
|
||||
export interface CreateAgentFromMcpInput {
|
||||
kind: "mcp";
|
||||
signal?: AbortSignal;
|
||||
provider: string;
|
||||
title: string;
|
||||
initialPrompt?: string;
|
||||
@@ -182,12 +180,10 @@ export async function createAgentCommand(
|
||||
? await resolveSessionCreateAgent(dependencies, input)
|
||||
: await resolveMcpCreateAgent(dependencies, input);
|
||||
|
||||
const signal = input.signal;
|
||||
signal?.throwIfAborted();
|
||||
const snapshot = await dependencies.agentManager.createAgent(
|
||||
resolved.config,
|
||||
input.kind === "session" ? input.agentId : undefined,
|
||||
{ ...resolved.createOptions, signal },
|
||||
resolved.createOptions,
|
||||
);
|
||||
|
||||
resolved.setupContinuation?.startAfterAgentCreate({
|
||||
@@ -201,7 +197,7 @@ export async function createAgentCommand(
|
||||
input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null });
|
||||
}
|
||||
if (resolved.prompt !== undefined) {
|
||||
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot, signal);
|
||||
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot);
|
||||
initialPromptStarted = sendResult.started;
|
||||
liveSnapshot = sendResult.liveSnapshot;
|
||||
initialPromptError = sendResult.error ?? null;
|
||||
@@ -453,7 +449,6 @@ async function sendInitialPrompt(
|
||||
dependencies: CreateAgentCommandDependencies,
|
||||
resolved: ResolvedCreateAgent,
|
||||
snapshot: ManagedAgent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ started: boolean; liveSnapshot: ManagedAgent; error?: unknown }> {
|
||||
try {
|
||||
const prompt = resolved.prompt;
|
||||
@@ -463,7 +458,6 @@ async function sendInitialPrompt(
|
||||
const liveSnapshot = await startCreatedAgentInitialPrompt({
|
||||
agentManager: dependencies.agentManager,
|
||||
agentId: snapshot.id,
|
||||
signal,
|
||||
snapshot,
|
||||
prompt,
|
||||
runOptions: resolved.runOptions,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { AgentRequests, AgentRequestError } from "./index.js";
|
||||
import { AgentRequests } from "./index.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => {
|
||||
@@ -141,210 +141,3 @@ test("failed local message preparation does not leave an ambiguous receipt", asy
|
||||
await requests.send(input);
|
||||
expect(sends).toBe(1);
|
||||
});
|
||||
|
||||
test("canceling startup rejects its waiter and fences later phases and retries", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
const context = { key: "arrival", deadlineAt: new Date(Date.now() + 60_000).toISOString() };
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const pending = requests.run(context, async (signal) => {
|
||||
if (!signal) throw new Error("missing operation signal");
|
||||
entered();
|
||||
await new Promise<void>((_, reject) =>
|
||||
signal.addEventListener("abort", () => reject(signal.reason), { once: true }),
|
||||
);
|
||||
});
|
||||
const failed = expect(pending).rejects.toThrow("agent_request_canceled");
|
||||
await started;
|
||||
await requests.cancel(context.key);
|
||||
await failed;
|
||||
let sent = false;
|
||||
await expect(
|
||||
new AgentRequests(directory).run(context, async () => {
|
||||
sent = true;
|
||||
}),
|
||||
).rejects.toThrow("agent_request_canceled");
|
||||
expect(sent).toBe(false);
|
||||
expect(await requests.run({ ...context, key: "other-arrival" }, async () => "ok")).toBe("ok");
|
||||
});
|
||||
|
||||
test("an expired startup deadline cannot launch even without a cancel frame", async () => {
|
||||
const { requests } = await fixture();
|
||||
let launched = false;
|
||||
await expect(
|
||||
requests.run({ key: "expired", deadlineAt: "2000-01-01T00:00:00.000Z" }, async () => {
|
||||
launched = true;
|
||||
}),
|
||||
).rejects.toThrow("agent_request_canceled");
|
||||
expect(launched).toBe(false);
|
||||
});
|
||||
|
||||
test("a noncooperative operation leaves explicit pending cleanup without trapping cancel callers", async () => {
|
||||
const { requests } = await fixture();
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const pending = requests.run(
|
||||
{ key: "hung", deadlineAt: new Date(Date.now() + 60_000).toISOString() },
|
||||
async () => {
|
||||
entered();
|
||||
await gate;
|
||||
},
|
||||
);
|
||||
const failed = expect(pending).rejects.toThrow("agent_request_canceled");
|
||||
await started;
|
||||
expect(await requests.cancelOperation("hung", "conversation")).toEqual({
|
||||
agentId: null,
|
||||
outcome: "pending",
|
||||
});
|
||||
await failed;
|
||||
release();
|
||||
await expect
|
||||
.poll(() => requests.cancelOperation("hung", "conversation"))
|
||||
.toEqual({ agentId: null, outcome: "settled" });
|
||||
});
|
||||
|
||||
test("a reconstructed journal reports interrupted startup as unknown and never launches a canceled phase", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const context = { key: "interrupted", deadlineAt: new Date(Date.now() + 60_000).toISOString() };
|
||||
const pending = requests.run(context, async () => {
|
||||
entered();
|
||||
await gate;
|
||||
});
|
||||
await started;
|
||||
const restarted = new AgentRequests(directory);
|
||||
const replays = await Promise.allSettled([
|
||||
restarted.run(context, async () => "must not launch"),
|
||||
restarted.run(context, async () => "must not launch"),
|
||||
]);
|
||||
expect(replays).toEqual([
|
||||
{ status: "rejected", reason: new AgentRequestError("agent_request_outcome_unknown") },
|
||||
{ status: "rejected", reason: new AgentRequestError("agent_request_outcome_unknown") },
|
||||
]);
|
||||
expect(await restarted.cancelOperation(context.key, "conversation")).toEqual({
|
||||
agentId: null,
|
||||
outcome: "unknown",
|
||||
});
|
||||
await expect(restarted.run(context, async () => "must not run")).rejects.toThrow(
|
||||
"agent_request_outcome_unknown",
|
||||
);
|
||||
release();
|
||||
await pending;
|
||||
});
|
||||
|
||||
test("a live deadline settles a hung waiter without a cancel frame and preserves late cleanup", async () => {
|
||||
const { directory } = await fixture();
|
||||
let expire!: () => void;
|
||||
const now = Date.parse("2026-09-09T00:00:00Z");
|
||||
const requests = new AgentRequests(directory, {
|
||||
now: () => now,
|
||||
schedule(callback, delayMs) {
|
||||
expect(delayMs).toBe(120_000);
|
||||
expire = callback;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let enter!: () => void;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
const pending = requests.run(
|
||||
{ key: "deadline", deadlineAt: new Date(now + 120_000).toISOString() },
|
||||
async () => {
|
||||
enter();
|
||||
await gate;
|
||||
},
|
||||
);
|
||||
const rejected = expect(pending).rejects.toThrow("agent_request_canceled");
|
||||
await entered;
|
||||
expire();
|
||||
await rejected;
|
||||
expect(await requests.inspectOperation("deadline", "conversation")).toMatchObject({
|
||||
outcome: "pending",
|
||||
});
|
||||
await expect(requests.run({ key: "deadline" }, async () => "must not launch")).rejects.toThrow(
|
||||
"agent_request_canceled",
|
||||
);
|
||||
release();
|
||||
await requests.cancelAndSettle("deadline");
|
||||
expect(await requests.inspectOperation("deadline", "conversation")).toMatchObject({
|
||||
outcome: "settled",
|
||||
});
|
||||
expect(await requests.run({ key: "next" }, async () => "ready")).toBe("ready");
|
||||
});
|
||||
|
||||
test("retransmitted phases serialize while other operation keys continue", async () => {
|
||||
const { requests } = await fixture();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const order: string[] = [];
|
||||
const first = requests.run({ key: "arrival" }, async () => {
|
||||
order.push("first");
|
||||
entered();
|
||||
await gate;
|
||||
order.push("settled");
|
||||
});
|
||||
await started;
|
||||
const second = requests.run({ key: "arrival" }, async () => {
|
||||
order.push("second");
|
||||
});
|
||||
await requests.run({ key: "other" }, async () => {
|
||||
order.push("other");
|
||||
});
|
||||
expect(order).toEqual(["first", "other"]);
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
expect(order).toEqual(["first", "other", "settled", "second"]);
|
||||
});
|
||||
|
||||
test("successful control receipts survive restart and reject changed targets", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
let archives = 0;
|
||||
const action = async () => {
|
||||
archives++;
|
||||
};
|
||||
await requests.control({ key: "cleanup" }, { workspaceId: "one" }, action);
|
||||
const restarted = new AgentRequests(directory);
|
||||
await restarted.control({ key: "cleanup" }, { workspaceId: "one" }, action);
|
||||
expect(archives).toBe(1);
|
||||
await expect(
|
||||
restarted.control({ key: "cleanup" }, { workspaceId: "two" }, action),
|
||||
).rejects.toThrow("agent_request_key_conflict");
|
||||
});
|
||||
|
||||
test("refused provider cancellation remains unknown after restart", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
await expect(
|
||||
requests.run({ key: "refused" }, async () => {
|
||||
throw new AgentRequestError("agent_request_outcome_unknown");
|
||||
}),
|
||||
).rejects.toThrow("agent_request_outcome_unknown");
|
||||
expect(
|
||||
await new AgentRequests(directory).inspectOperation("refused", "conversation"),
|
||||
).toMatchObject({ outcome: "unknown" });
|
||||
});
|
||||
|
||||
@@ -11,253 +11,11 @@ const ReceiptSchema = z.object({
|
||||
});
|
||||
type Receipt = z.infer<typeof ReceiptSchema>;
|
||||
|
||||
export class AgentRequestError extends Error {
|
||||
constructor(
|
||||
public readonly code:
|
||||
| "agent_request_canceled"
|
||||
| "agent_request_outcome_unknown"
|
||||
| "agent_request_key_conflict",
|
||||
) {
|
||||
super(code);
|
||||
this.name = "AgentRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestClock {
|
||||
now(): number;
|
||||
schedule(callback: () => void, delayMs: number): () => void;
|
||||
}
|
||||
const systemClock: RequestClock = {
|
||||
now: () => Date.now(),
|
||||
schedule(callback, delayMs) {
|
||||
const timer = setTimeout(callback, delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
|
||||
/** One daemon-owned request journal, shared by all of its socket sessions. */
|
||||
export class AgentRequests {
|
||||
private readonly pending = new Map<string, Promise<unknown>>();
|
||||
private readonly operations = new Map<
|
||||
string,
|
||||
{ controllers: Set<AbortController>; initialized: Promise<void>; unknown: boolean }
|
||||
>();
|
||||
private readonly operationWrites = new Map<string, Promise<void>>();
|
||||
private readonly pending = new Map<string, Promise<string>>();
|
||||
|
||||
constructor(
|
||||
private readonly directory: string,
|
||||
private readonly clock: RequestClock = systemClock,
|
||||
) {}
|
||||
|
||||
/** Runtime cancellation belongs to the same journal as idempotent create/send receipts. */
|
||||
async run<T>(
|
||||
context: { key: string; deadlineAt?: string } | undefined,
|
||||
operation: (signal?: AbortSignal) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!context) return operation();
|
||||
let active = this.operations.get(context.key);
|
||||
if (!active) {
|
||||
const previousWrite = this.operationWrites.get(context.key);
|
||||
active = {
|
||||
controllers: new Set(),
|
||||
unknown: false,
|
||||
initialized: (async () => {
|
||||
await previousWrite;
|
||||
if (await this.wasInterrupted(context.key))
|
||||
throw new AgentRequestError("agent_request_outcome_unknown");
|
||||
})(),
|
||||
};
|
||||
this.operations.set(context.key, active);
|
||||
}
|
||||
const operationState = active;
|
||||
const controller = new AbortController();
|
||||
const controllers = active.controllers;
|
||||
controllers.add(controller);
|
||||
let clearTimer: (() => void) | undefined;
|
||||
let abort: (() => void) | undefined;
|
||||
let started = false;
|
||||
let recorded = false;
|
||||
try {
|
||||
await active.initialized;
|
||||
const remaining =
|
||||
context.deadlineAt === undefined
|
||||
? undefined
|
||||
: Date.parse(context.deadlineAt) - this.clock.now();
|
||||
if (
|
||||
(remaining !== undefined && (!Number.isFinite(remaining) || remaining <= 0)) ||
|
||||
(await this.wasCanceled(context.key))
|
||||
) {
|
||||
throw new AgentRequestError("agent_request_canceled");
|
||||
}
|
||||
controller.signal.throwIfAborted();
|
||||
if (remaining !== undefined)
|
||||
clearTimer = this.clock.schedule(
|
||||
() => controller.abort(new AgentRequestError("agent_request_canceled")),
|
||||
remaining,
|
||||
);
|
||||
await this.recordOperationState(context.key);
|
||||
recorded = true;
|
||||
controller.signal.throwIfAborted();
|
||||
started = true;
|
||||
const operationKey = digest(["operation", context.key]);
|
||||
const previous = this.pending.get(operationKey) ?? Promise.resolve();
|
||||
const tracked = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
controller.signal.throwIfAborted();
|
||||
return operation(controller.signal);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof AgentRequestError && error.code === "agent_request_outcome_unknown")
|
||||
operationState.unknown = true;
|
||||
throw error;
|
||||
})
|
||||
.finally(async () => {
|
||||
controllers.delete(controller);
|
||||
if (controllers.size === 0) this.operations.delete(context.key);
|
||||
await this.recordOperationState(context.key, operationState.unknown);
|
||||
if (this.pending.get(operationKey) === tracked) this.pending.delete(operationKey);
|
||||
});
|
||||
this.pending.set(operationKey, tracked);
|
||||
return await Promise.race([
|
||||
tracked,
|
||||
new Promise<never>((_, reject) => {
|
||||
abort = () => reject(controller.signal.reason);
|
||||
controller.signal.addEventListener("abort", abort, { once: true });
|
||||
if (controller.signal.aborted) abort();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimer?.();
|
||||
if (abort) controller.signal.removeEventListener("abort", abort);
|
||||
if (controller.signal.aborted) await this.recordCancellation(context.key);
|
||||
if (!started) {
|
||||
controllers.delete(controller);
|
||||
if (controllers.size === 0) this.operations.delete(context.key);
|
||||
if (recorded) await this.recordOperationState(context.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(key: string): Promise<void> {
|
||||
// Persist before acknowledging: a delayed request or a new socket must also be fenced.
|
||||
await this.recordCancellation(key);
|
||||
for (const controller of this.operations.get(key)?.controllers ?? []) {
|
||||
controller.abort(new AgentRequestError("agent_request_canceled"));
|
||||
}
|
||||
}
|
||||
|
||||
async control(
|
||||
context: { key: string; deadlineAt?: string } | undefined,
|
||||
request: unknown,
|
||||
operation: () => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
await this.run(context, async () => {
|
||||
if (!context) {
|
||||
await operation();
|
||||
return;
|
||||
}
|
||||
const file = path.join(this.directory, `control-${digest(context.key)}.json`);
|
||||
const fingerprint = digest(request);
|
||||
try {
|
||||
const receipt = z
|
||||
.object({ fingerprint: z.string() })
|
||||
.parse(JSON.parse(await readFile(file, "utf8")));
|
||||
if (receipt.fingerprint !== fingerprint)
|
||||
throw new AgentRequestError("agent_request_key_conflict");
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
||||
}
|
||||
await operation();
|
||||
await writeJsonFileAtomic(file, { fingerprint });
|
||||
});
|
||||
}
|
||||
|
||||
async cancelAndSettle(key: string): Promise<void> {
|
||||
await this.cancel(key);
|
||||
await this.pending.get(digest(["operation", key]))?.catch(() => undefined);
|
||||
await this.operationWrites.get(key);
|
||||
if (await this.wasInterrupted(key))
|
||||
throw new AgentRequestError("agent_request_outcome_unknown");
|
||||
}
|
||||
|
||||
async cancelOperation(
|
||||
key: string,
|
||||
creationKey: string,
|
||||
): Promise<{
|
||||
agentId: string | null;
|
||||
outcome: "settled" | "pending" | "unknown";
|
||||
}> {
|
||||
await this.cancel(key);
|
||||
return this.inspectOperation(key, creationKey);
|
||||
}
|
||||
|
||||
async inspectOperation(
|
||||
key: string,
|
||||
creationKey: string,
|
||||
): Promise<{
|
||||
agentId: string | null;
|
||||
outcome: "settled" | "pending" | "unknown";
|
||||
}> {
|
||||
await this.operationWrites.get(key);
|
||||
const active = this.operations.has(key);
|
||||
const receipt = await readReceipt(
|
||||
path.join(this.directory, `${digest(["create", creationKey])}.json`),
|
||||
);
|
||||
let outcome: "pending" | "unknown" | "settled" = "settled";
|
||||
if (receipt?.state === "pending" || (await this.wasInterrupted(key))) outcome = "unknown";
|
||||
if (active) outcome = "pending";
|
||||
return { agentId: receipt?.agentId ?? null, outcome };
|
||||
}
|
||||
|
||||
private recordOperationState(key: string, unknown = false): Promise<void> {
|
||||
const previous = this.operationWrites.get(key) ?? Promise.resolve();
|
||||
const write = previous
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
writeJsonFileAtomic(path.join(this.directory, `operation-${digest(key)}.json`), {
|
||||
state: unknown || this.operations.has(key) ? "pending" : "settled",
|
||||
}),
|
||||
);
|
||||
this.operationWrites.set(key, write);
|
||||
void write
|
||||
.finally(() => {
|
||||
if (this.operationWrites.get(key) === write) this.operationWrites.delete(key);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return write;
|
||||
}
|
||||
|
||||
private async wasInterrupted(key: string): Promise<boolean> {
|
||||
try {
|
||||
const state = z
|
||||
.object({ state: z.enum(["pending", "settled"]) })
|
||||
.parse(
|
||||
JSON.parse(
|
||||
await readFile(path.join(this.directory, `operation-${digest(key)}.json`), "utf8"),
|
||||
),
|
||||
);
|
||||
return state.state === "pending";
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private recordCancellation(key: string): Promise<void> {
|
||||
return writeJsonFileAtomic(path.join(this.directory, `canceled-${digest(key)}.json`), true);
|
||||
}
|
||||
|
||||
private async wasCanceled(key: string): Promise<boolean> {
|
||||
try {
|
||||
await readFile(path.join(this.directory, `canceled-${digest(key)}.json`));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
constructor(private readonly directory: string) {}
|
||||
|
||||
create(input: {
|
||||
key: string;
|
||||
@@ -331,11 +89,10 @@ export class AgentRequests {
|
||||
const file = path.join(this.directory, `${key}.json`);
|
||||
const existing = await readReceipt(file);
|
||||
if (existing) {
|
||||
if (existing.fingerprint !== fingerprint)
|
||||
throw new AgentRequestError("agent_request_key_conflict");
|
||||
if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict");
|
||||
if (existing.state === "completed") return existing.agentId;
|
||||
if (!(await operation.recover(existing.agentId))) {
|
||||
throw new AgentRequestError("agent_request_outcome_unknown");
|
||||
throw new Error("agent_request_outcome_unknown");
|
||||
}
|
||||
await writeJsonFileAtomic(file, { ...existing, state: "completed" });
|
||||
return existing.agentId;
|
||||
|
||||
@@ -6,8 +6,6 @@ type OutboundOperation = SessionOutboundMessage["type"];
|
||||
export type PermissionRequirement = DaemonPermission | readonly DaemonPermission[] | null;
|
||||
|
||||
const INBOUND_PERMISSION = {
|
||||
"agent.requests.inspect.request": ["workspace.write", "hub.execute"],
|
||||
"agent.requests.cancel.request": ["workspace.write", "hub.execute"],
|
||||
abort_request: "workspace.write",
|
||||
"agent.config.apply.request": ["workspace.write", "hub.execute"],
|
||||
"agent.detach.request": "workspace.write",
|
||||
@@ -83,6 +81,8 @@ const INBOUND_PERMISSION = {
|
||||
fetch_workspaces_request: ["workspace.read", "hub.execute"],
|
||||
"file.upload.request": "workspace.write",
|
||||
file_download_token_request: "workspace.read",
|
||||
"fs.content.search.request": "workspace.read",
|
||||
"fs.content.cancel.request": "workspace.read",
|
||||
file_explorer_request: "workspace.read",
|
||||
"forge.search.request": "workspace.read",
|
||||
"fs.entry.create.request": "workspace.write",
|
||||
@@ -208,8 +208,6 @@ const INBOUND_PERMISSION = {
|
||||
} as const satisfies Record<InboundOperation, PermissionRequirement>;
|
||||
|
||||
const OUTBOUND_PERMISSION = {
|
||||
"agent.requests.inspect.response": ["workspace.write", "hub.execute"],
|
||||
"agent.requests.cancel.response": ["workspace.write", "hub.execute"],
|
||||
activity_log: "workspace.read",
|
||||
"agent.config.apply.response": ["workspace.write", "hub.execute"],
|
||||
"agent.detach.response": "workspace.write",
|
||||
@@ -296,6 +294,8 @@ const OUTBOUND_PERMISSION = {
|
||||
fetch_workspaces_response: ["workspace.read", "hub.execute"],
|
||||
"file.upload.response": "workspace.write",
|
||||
file_download_token_response: "workspace.read",
|
||||
"fs.content.search.response": "workspace.read",
|
||||
"fs.content.cancel.response": "workspace.read",
|
||||
file_explorer_response: "workspace.read",
|
||||
"forge.search.response": "workspace.read",
|
||||
"fs.entry.create.response": "workspace.write",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AgentRequests } from "./agent/requests/index.js";
|
||||
import { describeHookWorkspace } from "./plugins/lifecycle/index.js";
|
||||
import express from "express";
|
||||
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
|
||||
@@ -857,7 +856,6 @@ export async function createPaseoDaemon(
|
||||
serviceProxyListenTarget = parseListenString(config.serviceProxy.standaloneListen);
|
||||
}
|
||||
|
||||
const agentRequests = new AgentRequests(path.join(config.paseoHome, "agent-requests"));
|
||||
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(config.paseoHome, "projects", "projects.json"),
|
||||
@@ -1259,7 +1257,6 @@ export async function createPaseoDaemon(
|
||||
createExecutionAgents: (daemonId) =>
|
||||
new DaemonExecutions({
|
||||
daemonId,
|
||||
agentRequests,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
createAgent,
|
||||
@@ -1720,7 +1717,6 @@ export async function createPaseoDaemon(
|
||||
pluginRuntime,
|
||||
orchestrationSkills,
|
||||
workspaceLabelService,
|
||||
agentRequests,
|
||||
);
|
||||
pluginRuntime.bindPaseoSessionHost(wsServer);
|
||||
await pluginRuntime.start();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { expect, test } from "vitest";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
|
||||
test("real client searches saved contents, cancels host work and can search again", async () => {
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), "paseo-content-rpc-"));
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
||||
try {
|
||||
await writeFile(path.join(cwd, "a.ts"), "const original = 'NEEDLE';\n");
|
||||
await client.connect();
|
||||
await client.fetchAgents();
|
||||
const result = await client.searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(
|
||||
result.status === "ok" && result.matches.map((match) => [match.path, match.text]),
|
||||
).toEqual([["a.ts", "NEEDLE"]]);
|
||||
const controller = new AbortController();
|
||||
const searching = client.searchWorkspaceContent(
|
||||
{ cwd, query: "absent" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
expect(await searching).toMatchObject({ status: "error", code: "cancelled" });
|
||||
// A complete empty result also reports the per-file ceiling the host applied, so the client
|
||||
// can say which files it never opened rather than claiming it read the whole workspace.
|
||||
expect(await client.searchWorkspaceContent({ cwd, query: "missing" })).toEqual({
|
||||
status: "ok",
|
||||
matches: [],
|
||||
limited: false,
|
||||
maxFileBytes: 1_048_576,
|
||||
});
|
||||
const preview = await client.readFile(cwd, "a.ts", undefined, 1_048_576);
|
||||
expect(new TextDecoder().decode(preview.bytes)).toBe("const original = 'NEEDLE';\n");
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
@@ -592,3 +592,44 @@ describe("file explorer service", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces the caller byte ceiling at the opened file for inline and streaming reads", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-read-budget-"));
|
||||
try {
|
||||
await writeFile(path.join(root, "growing.txt"), "small");
|
||||
const budget = (await stat(path.join(root, "growing.txt"))).size;
|
||||
await appendFile(path.join(root, "growing.txt"), " grew after precheck");
|
||||
await expect(
|
||||
readExplorerFile({ root, relativePath: "growing.txt", maxBytes: budget }),
|
||||
).rejects.toThrow("File is too large to display");
|
||||
await expect(
|
||||
streamExplorerFile({ root, relativePath: "growing.txt", maxBytes: budget }, async () => {
|
||||
throw new Error("Must reject before delivering content");
|
||||
}),
|
||||
).rejects.toThrow("File is too large to display");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves literal whitespace in existing file identities through read and write", async () => {
|
||||
const root = await createTempDir("paseo-literal-path-");
|
||||
try {
|
||||
await writeFile(path.join(root, " leading.txt"), "needle");
|
||||
await writeFile(path.join(root, "leading.txt"), "different file");
|
||||
const file = await readExplorerFile({ root, relativePath: " leading.txt" });
|
||||
expect(file.content).toBe("needle");
|
||||
const written = await writeExplorerFile({
|
||||
root,
|
||||
relativePath: " leading.txt",
|
||||
content: "updated",
|
||||
expectedModifiedAt: file.modifiedAt,
|
||||
expectedRevision: file.revision,
|
||||
});
|
||||
expect(written.status).toBe("written");
|
||||
expect(await readFile(path.join(root, " leading.txt"), "utf8")).toBe("updated");
|
||||
expect(await readFile(path.join(root, "leading.txt"), "utf8")).toBe("different file");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { constants, promises as fs, type BigIntStats, type Stats } from "fs";
|
||||
import type { FileHandle } from "fs/promises";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
||||
import { expandUserPath, resolvePathFromBase, toWorkspaceRelativePath } from "../path-utils.js";
|
||||
import { runGitCommand } from "../../utils/run-git-command.js";
|
||||
|
||||
export type ExplorerEntryKind = "file" | "directory";
|
||||
@@ -15,6 +15,7 @@ export interface ListDirectoryParams {
|
||||
}
|
||||
|
||||
export interface ReadFileParams {
|
||||
maxBytes?: number;
|
||||
root: string;
|
||||
relativePath: string;
|
||||
}
|
||||
@@ -193,8 +194,9 @@ export async function listDirectoryEntries({
|
||||
export async function readExplorerFile({
|
||||
root,
|
||||
relativePath,
|
||||
maxBytes,
|
||||
}: ReadFileParams): Promise<FileExplorerFile> {
|
||||
const file = await readExplorerFileBytes({ root, relativePath });
|
||||
const file = await readExplorerFileBytes({ root, relativePath, maxBytes });
|
||||
|
||||
if (file.kind === "image") {
|
||||
return {
|
||||
@@ -236,6 +238,7 @@ export async function readExplorerFile({
|
||||
export async function readExplorerFileBytes({
|
||||
root,
|
||||
relativePath,
|
||||
maxBytes,
|
||||
}: ReadFileParams): Promise<FileExplorerFileBytes> {
|
||||
const filePath = await resolveScopedPath({ root, relativePath });
|
||||
const handle = await openFileForRead(filePath.resolvedPath);
|
||||
@@ -247,6 +250,8 @@ export async function readExplorerFileBytes({
|
||||
throw new Error("Requested path is not a file");
|
||||
}
|
||||
|
||||
if (maxBytes !== undefined && stats.size > BigInt(maxBytes))
|
||||
throw new Error("File is too large to display");
|
||||
const ext = path.extname(filePath.resolvedPath).toLowerCase();
|
||||
const basePayload = {
|
||||
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
|
||||
@@ -255,7 +260,10 @@ export async function readExplorerFileBytes({
|
||||
revision: fileRevision(stats),
|
||||
};
|
||||
|
||||
const buffer = await handle.readFile();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of readFileHandleChunks(handle, Number(stats.size), fileRevision(stats)))
|
||||
chunks.push(Buffer.from(chunk));
|
||||
const buffer = Buffer.concat(chunks);
|
||||
if (ext in IMAGE_MIME_TYPES) {
|
||||
return {
|
||||
...basePayload,
|
||||
@@ -289,7 +297,7 @@ export async function readExplorerFileBytes({
|
||||
}
|
||||
|
||||
export async function streamExplorerFile(
|
||||
{ root, relativePath }: ReadFileParams,
|
||||
{ root, relativePath, maxBytes }: ReadFileParams,
|
||||
consume: (file: FileExplorerFileStream) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const filePath = await resolveScopedPath({ root, relativePath });
|
||||
@@ -301,6 +309,8 @@ export async function streamExplorerFile(
|
||||
throw new Error("Requested path is not a file");
|
||||
}
|
||||
|
||||
if (maxBytes !== undefined && stats.size > BigInt(maxBytes))
|
||||
throw new Error("File is too large to display");
|
||||
const advertisedSize = Number(stats.size);
|
||||
const advertisedRevision = fileRevision(stats);
|
||||
const ext = path.extname(filePath.resolvedPath).toLowerCase();
|
||||
@@ -861,7 +871,7 @@ function normalizeRelativePath({ root, targetPath }: { root: string; targetPath:
|
||||
const normalizedRoot = expandUserPath(root);
|
||||
const normalizedTarget = expandUserPath(targetPath);
|
||||
const relative = path.relative(normalizedRoot, normalizedTarget);
|
||||
return relative === "" ? "." : relative.split(path.sep).join("/");
|
||||
return relative === "" ? "." : toWorkspaceRelativePath(relative);
|
||||
}
|
||||
|
||||
function textMimeTypeForExtension(ext: string): string {
|
||||
|
||||
@@ -326,40 +326,3 @@ test("failed create never archives a reused worktree", async () => {
|
||||
});
|
||||
expect(await hub.worktreeState(worktreeCwd!)).toEqual({ exists: true, listed: true });
|
||||
});
|
||||
|
||||
test("control cancels a held legacy provider create and fences a delayed replay", async () => {
|
||||
const hub = await launchRelationship();
|
||||
hub.holdAgentCreation();
|
||||
hub.beginOwnedCreate("held-create", "expired-create");
|
||||
await hub.agentCreationAttempts(1);
|
||||
const creation = hub.ownedCreateResult("held-create");
|
||||
const control = hub.controlExecution("expired-create", "archive");
|
||||
try {
|
||||
expect(await control).toMatchObject({ success: true });
|
||||
expect(await creation).toMatchObject({ payload: { success: false } });
|
||||
} finally {
|
||||
hub.finishAgentCreation();
|
||||
}
|
||||
hub.beginOwnedCreate("late-create", "expired-create");
|
||||
expect(await hub.ownedCreateResult("late-create")).toMatchObject({ payload: { success: false } });
|
||||
});
|
||||
|
||||
test("the legacy absolute deadline cancels provider creation without a control frame", async () => {
|
||||
const hub = await launchRelationship();
|
||||
hub.holdAgentCreation();
|
||||
hub.beginOwnedCreate("deadline-create", "deadline-create", {
|
||||
deadlineAt: new Date(Date.now() + 1_000).toISOString(),
|
||||
});
|
||||
try {
|
||||
await hub.agentCreationAttempts(1);
|
||||
expect(await hub.ownedCreateResult("deadline-create")).toMatchObject({
|
||||
payload: { success: false },
|
||||
});
|
||||
} finally {
|
||||
hub.finishAgentCreation();
|
||||
}
|
||||
hub.beginOwnedCreate("deadline-replay", "deadline-create");
|
||||
expect(await hub.ownedCreateResult("deadline-replay")).toMatchObject({
|
||||
payload: { success: false },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { AgentRequests } from "../agent/requests/index.js";
|
||||
import type {
|
||||
AgentSnapshotPayload,
|
||||
AgentStreamEventPayload,
|
||||
@@ -18,7 +17,6 @@ import { daemonExecutionKey, type DaemonAgentOwner } from "../agent/agent-owner.
|
||||
|
||||
export interface HubExecutionAgentCreateInput {
|
||||
executionId: string;
|
||||
deadlineAt?: string;
|
||||
provider: string;
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
@@ -55,7 +53,6 @@ export type OwnedAgentEvent =
|
||||
|
||||
interface DaemonExecutionsOptions {
|
||||
daemonId: string;
|
||||
agentRequests: AgentRequests;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
createAgent: BoundCreateAgentCommand;
|
||||
@@ -125,8 +122,6 @@ export class DaemonExecutions implements HubExecutionAgents {
|
||||
const pending = this.pendingControlActions.get(actionKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const canceled = this.options.agentRequests.cancelAndSettle(executionKey);
|
||||
void canceled.catch(() => undefined);
|
||||
const previous =
|
||||
this.controlTails.get(executionKey) ??
|
||||
this.pendingCreates.get(executionKey)?.then(() => undefined) ??
|
||||
@@ -134,7 +129,6 @@ export class DaemonExecutions implements HubExecutionAgents {
|
||||
const authorityGeneration = this.authorityGeneration;
|
||||
const control = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => canceled)
|
||||
.then(() => this.controlOwnedExecution(owner, input, authorityGeneration));
|
||||
this.pendingControlActions.set(actionKey, control);
|
||||
this.controlTails.set(executionKey, control);
|
||||
@@ -154,9 +148,6 @@ export class DaemonExecutions implements HubExecutionAgents {
|
||||
this.authorityActive = false;
|
||||
this.authorityGeneration++;
|
||||
await Promise.allSettled([
|
||||
...[...this.pendingCreates.keys()].map((key) =>
|
||||
this.options.agentRequests.cancelAndSettle(key),
|
||||
),
|
||||
...this.pendingCreates.values(),
|
||||
...this.pendingControlActions.values(),
|
||||
]);
|
||||
@@ -189,25 +180,12 @@ export class DaemonExecutions implements HubExecutionAgents {
|
||||
requireHubMcpNamespace(input.mcpServers);
|
||||
requireToolPolicyServers(input.toolPolicy, input.mcpServers);
|
||||
|
||||
return this.options.agentRequests.run(
|
||||
{ key: daemonExecutionKey(owner), deadlineAt: input.deadlineAt },
|
||||
(signal) => this.createNewOwnedAgent(owner, input, authorityGeneration, signal),
|
||||
);
|
||||
}
|
||||
|
||||
private async createNewOwnedAgent(
|
||||
owner: DaemonAgentOwner,
|
||||
input: HubExecutionAgentCreateInput,
|
||||
authorityGeneration: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OwnedAgentSnapshot> {
|
||||
let createdWorktree: CreatePaseoWorktreeWorkflowResult | null = null;
|
||||
let createdAgentId: string | null = null;
|
||||
let result: Awaited<ReturnType<BoundCreateAgentCommand>>;
|
||||
try {
|
||||
result = await this.createAgentCommand({
|
||||
kind: "mcp",
|
||||
signal,
|
||||
provider: input.model ? `${input.provider}/${input.model}` : input.provider,
|
||||
title: input.prompt,
|
||||
initialPrompt: input.prompt,
|
||||
@@ -237,7 +215,6 @@ export class DaemonExecutions implements HubExecutionAgents {
|
||||
createdAgentId = created.agentId;
|
||||
},
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
this.requireAuthority(authorityGeneration);
|
||||
requireExecutionWorkspaceId(result.liveSnapshot);
|
||||
} catch (error) {
|
||||
|
||||
@@ -149,7 +149,6 @@ export class HubExecutionController {
|
||||
if (!isAbsolute(message.cwd)) throw new Error("Hub agent cwd must be absolute");
|
||||
const result = await this.agents.create({
|
||||
executionId: message.executionId,
|
||||
deadlineAt: message.deadlineAt,
|
||||
provider: message.provider,
|
||||
cwd: message.cwd,
|
||||
prompt: message.prompt,
|
||||
|
||||
@@ -251,7 +251,7 @@ test("Hub interrupts an owned running execution idempotently", async () => {
|
||||
expect(hub.ownedAgentIsRunning(created.payload.agentId!)).toBe(false);
|
||||
});
|
||||
|
||||
test("Hub control cancels an in-flight create of the same execution", async () => {
|
||||
test("Hub control waits for an in-flight create of the same execution", async () => {
|
||||
const hub = await launchRelationship();
|
||||
hub.holdAgentCreation();
|
||||
hub.beginOwnedCreate("pending-control-create", "execution-pending-control", {
|
||||
@@ -264,8 +264,10 @@ test("Hub control cancels an in-flight create of the same execution", async () =
|
||||
const created = await hub.ownedCreateResult("pending-control-create");
|
||||
const archived = await hub.executionControlResult("pending-control-archive");
|
||||
|
||||
expect(created).toMatchObject({ payload: { success: false, agentId: null } });
|
||||
expect(created).toMatchObject({ payload: { success: true, agentId: expect.any(String) } });
|
||||
expect(archived).toMatchObject({ success: true, error: null, action: "archive" });
|
||||
expect(created.payload.agent?.workspaceId).toEqual(expect.any(String));
|
||||
expect(await hub.ownedAgentArchivedAt(created.payload.agentId!)).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
test("Hub archives an execution workspace on a local checkout", async () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AgentRequests } from "../../agent/requests/index.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
@@ -1559,7 +1558,6 @@ export class HubRelationshipHarness {
|
||||
|
||||
private executionsForReconstruction(manager: AgentManager, storage: AgentStorage) {
|
||||
return new DaemonExecutions({
|
||||
agentRequests: new AgentRequests(path.join(this.paseoHome, "agent-requests")),
|
||||
daemonId: this.relationshipFile()!.relationship.daemonId,
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { sep } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { toWorkspaceRelativePath } from "./path-utils.js";
|
||||
|
||||
// Workspace-relative identities cross the wire with "/" whatever the host separator is, so a
|
||||
// client never has to guess which backslash was a separator and which was a file name character.
|
||||
test("renders a host-relative path with forward slashes", () => {
|
||||
expect(toWorkspaceRelativePath(["src", "app.ts"].join(sep))).toBe("src/app.ts");
|
||||
expect(toWorkspaceRelativePath("app.ts")).toBe("app.ts");
|
||||
});
|
||||
|
||||
test.skipIf(sep === "\\")("leaves a POSIX file name containing a backslash intact", () => {
|
||||
expect(toWorkspaceRelativePath("a\\b.txt")).toBe("a\\b.txt");
|
||||
});
|
||||
|
||||
test.skipIf(sep !== "\\")("converts every Windows separator", () => {
|
||||
expect(toWorkspaceRelativePath("src\\nested\\app.ts")).toBe("src/nested/app.ts");
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, posix, resolve, win32 } from "node:path";
|
||||
import { isAbsolute, posix, resolve, sep, win32 } from "node:path";
|
||||
|
||||
export function assertAbsolutePath(cwd: string): void {
|
||||
if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) {
|
||||
@@ -12,19 +12,17 @@ function hasHomePrefix(value: string): boolean {
|
||||
}
|
||||
|
||||
export function expandUserPath(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (hasHomePrefix(trimmed)) {
|
||||
return resolve(homedir(), trimmed.slice(2));
|
||||
if (hasHomePrefix(value)) {
|
||||
return resolve(homedir(), value.slice(2));
|
||||
}
|
||||
return resolve(trimmed);
|
||||
return resolve(value);
|
||||
}
|
||||
|
||||
export function resolvePathFromBase(baseCwd: string, requestedPath: string): string {
|
||||
const trimmed = requestedPath.trim();
|
||||
if (hasHomePrefix(trimmed) || isAbsolute(trimmed)) {
|
||||
return expandUserPath(trimmed);
|
||||
if (hasHomePrefix(requestedPath) || isAbsolute(requestedPath)) {
|
||||
return expandUserPath(requestedPath);
|
||||
}
|
||||
return resolve(baseCwd, trimmed);
|
||||
return resolve(baseCwd, requestedPath);
|
||||
}
|
||||
|
||||
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
|
||||
@@ -40,3 +38,12 @@ export function isSameOrDescendantPath(basePath: string, candidatePath: string):
|
||||
normalizedCandidate === normalizedBase || normalizedCandidate.startsWith(normalizedBase + "/")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a host-relative path the way every workspace-relative identity crosses the wire: this
|
||||
* host's separator becomes "/", and every other character survives, so a Unix file name that
|
||||
* contains a backslash stays that file rather than becoming a directory on the client.
|
||||
*/
|
||||
export function toWorkspaceRelativePath(relative: string): string {
|
||||
return relative.split(sep).join("/");
|
||||
}
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { z } from "zod";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { createTestAgentClient } from "./test-utils/fake-agent-client.js";
|
||||
|
||||
const Envelope = z.object({
|
||||
type: z.literal("session"),
|
||||
message: z.object({ type: z.string(), payload: z.record(z.string(), z.unknown()) }),
|
||||
});
|
||||
|
||||
function exchange(
|
||||
socket: WebSocket,
|
||||
frame: unknown,
|
||||
accepts: (type: string, payload: Record<string, unknown>) => boolean,
|
||||
) {
|
||||
return new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Daemon response missing"));
|
||||
}, 10_000);
|
||||
const receive = (data: WebSocket.RawData) => {
|
||||
const parsed = Envelope.safeParse(JSON.parse(data.toString()));
|
||||
if (parsed.success && accepts(parsed.data.message.type, parsed.data.message.payload)) {
|
||||
cleanup();
|
||||
resolve(parsed.data.message.payload);
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
socket.off("message", receive);
|
||||
};
|
||||
socket.on("message", receive);
|
||||
socket.send(JSON.stringify(frame));
|
||||
});
|
||||
}
|
||||
function request(socket: WebSocket, message: Record<string, unknown>) {
|
||||
const requestId = randomUUID();
|
||||
return exchange(
|
||||
socket,
|
||||
{ type: "session", message: { ...message, requestId } },
|
||||
(_type, payload) => payload["requestId"] === requestId,
|
||||
);
|
||||
}
|
||||
async function connect(port: number) {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("open", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
const info = await exchange(
|
||||
socket,
|
||||
{ type: "hello", clientId: randomUUID(), clientType: "browser", protocolVersion: 1 },
|
||||
(_type, payload) => payload["status"] === "server_info",
|
||||
);
|
||||
expect(info["features"]).toMatchObject({ agentRequestCancellation: true });
|
||||
return socket;
|
||||
}
|
||||
|
||||
test("socket cancellation settles a held create, disposes its late provider session, and fences a reconnect replay", async () => {
|
||||
let started!: () => void;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let creates = 0;
|
||||
let closes = 0;
|
||||
const provider = createTestAgentClient("codex", {
|
||||
beforeCreateSession: async () => {
|
||||
creates++;
|
||||
started();
|
||||
await gate;
|
||||
},
|
||||
closeSession: async () => {
|
||||
closes++;
|
||||
},
|
||||
});
|
||||
const host = await createTestPaseoDaemon({ agentClients: { codex: provider } });
|
||||
let socket = await connect(host.port);
|
||||
const key = randomUUID();
|
||||
const creationKey = randomUUID();
|
||||
const config = { provider: "codex", cwd: host.paseoHome };
|
||||
const operation = { key, deadlineAt: new Date(Date.now() + 60_000).toISOString() };
|
||||
try {
|
||||
const unkeyed = await request(socket, {
|
||||
type: "create_agent_request",
|
||||
config,
|
||||
operation,
|
||||
initialPrompt: "must not launch",
|
||||
});
|
||||
expect(unkeyed).toMatchObject({ status: "agent_create_failed" });
|
||||
expect(creates).toBe(0);
|
||||
const creation = request(socket, {
|
||||
type: "create_agent_request",
|
||||
config,
|
||||
idempotencyKey: creationKey,
|
||||
operation,
|
||||
});
|
||||
await entered;
|
||||
const canceled = await request(socket, {
|
||||
type: "agent.requests.cancel.request",
|
||||
key,
|
||||
creationKey,
|
||||
});
|
||||
expect(["pending", "settled"]).toContain(canceled["outcome"]);
|
||||
expect(await creation).not.toMatchObject({ status: "agent_created" });
|
||||
release();
|
||||
await expect.poll(() => closes).toBe(1);
|
||||
await expect
|
||||
.poll(() => request(socket, { type: "agent.requests.inspect.request", key, creationKey }))
|
||||
.toMatchObject({ outcome: "settled", agentId: null });
|
||||
expect(host.daemon.agentManager.listAgents()).toEqual([]);
|
||||
|
||||
socket.close();
|
||||
socket = await connect(host.port);
|
||||
const replay = await request(socket, {
|
||||
type: "create_agent_request",
|
||||
config,
|
||||
idempotencyKey: creationKey,
|
||||
operation,
|
||||
});
|
||||
expect(replay).not.toMatchObject({ status: "agent_created" });
|
||||
expect(creates).toBe(1);
|
||||
const next = await request(socket, {
|
||||
type: "create_agent_request",
|
||||
config,
|
||||
idempotencyKey: creationKey,
|
||||
operation: { ...operation, key: randomUUID() },
|
||||
});
|
||||
expect(next).toMatchObject({ status: "agent_created" });
|
||||
const repeated = await request(socket, {
|
||||
type: "create_agent_request",
|
||||
config,
|
||||
idempotencyKey: creationKey,
|
||||
operation: { ...operation, key: randomUUID() },
|
||||
});
|
||||
expect(repeated["agentId"]).toBe(next["agentId"]);
|
||||
expect(creates).toBe(2);
|
||||
expect(host.daemon.agentManager.listAgents()).toHaveLength(1);
|
||||
} finally {
|
||||
release();
|
||||
socket.close();
|
||||
await host.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("expiry during provider send settles the socket caller and retains cleanup ownership until cancellation finishes", async () => {
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let turns = 0;
|
||||
const provider = createTestAgentClient("codex", {
|
||||
beforeStartTurn: async () => {
|
||||
entered();
|
||||
await gate;
|
||||
},
|
||||
onStartTurn: () => {
|
||||
turns++;
|
||||
},
|
||||
});
|
||||
const host = await createTestPaseoDaemon({ agentClients: { codex: provider } });
|
||||
const socket = await connect(host.port);
|
||||
const creationKey = randomUUID();
|
||||
const key = randomUUID();
|
||||
try {
|
||||
const created = await request(socket, {
|
||||
type: "create_agent_request",
|
||||
config: { provider: "codex", cwd: host.paseoHome },
|
||||
idempotencyKey: creationKey,
|
||||
});
|
||||
const agentId = z.string().parse(created["agentId"]);
|
||||
const messageId = randomUUID();
|
||||
const operation = { key, deadlineAt: new Date(Date.now() + 60_000).toISOString() };
|
||||
const sending = request(socket, {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId,
|
||||
text: "hello",
|
||||
operation,
|
||||
});
|
||||
await started;
|
||||
await request(socket, { type: "agent.requests.cancel.request", key, creationKey });
|
||||
expect(await sending).not.toMatchObject({ accepted: true });
|
||||
// The provider has not acknowledged start. Cancellation cannot claim this work settled yet.
|
||||
expect(
|
||||
await request(socket, { type: "agent.requests.inspect.request", key, creationKey }),
|
||||
).toMatchObject({ outcome: "pending", agentId });
|
||||
release();
|
||||
await expect
|
||||
.poll(() => request(socket, { type: "agent.requests.inspect.request", key, creationKey }))
|
||||
.toMatchObject({ outcome: "settled", agentId });
|
||||
expect(host.daemon.agentManager.hasInFlightRun(agentId)).toBe(false);
|
||||
await request(socket, {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId,
|
||||
text: "hello",
|
||||
operation,
|
||||
});
|
||||
expect(turns).toBe(1);
|
||||
const next = await request(socket, {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId: randomUUID(),
|
||||
text: "next",
|
||||
operation: { ...operation, key: randomUUID() },
|
||||
});
|
||||
expect(next).toMatchObject({ accepted: true });
|
||||
expect(turns).toBe(2);
|
||||
} finally {
|
||||
release();
|
||||
socket.close();
|
||||
await host.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("a delayed duplicate archive acknowledgement cannot archive a restored continuation again", async () => {
|
||||
const host = await createTestPaseoDaemon({
|
||||
agentClients: { codex: createTestAgentClient("codex") },
|
||||
});
|
||||
const socket = await connect(host.port);
|
||||
try {
|
||||
const created = await request(socket, {
|
||||
type: "create_agent_request",
|
||||
config: { provider: "codex", cwd: host.paseoHome },
|
||||
idempotencyKey: randomUUID(),
|
||||
});
|
||||
const workspaceId = z.object({ workspaceId: z.string() }).parse(created["agent"]).workspaceId;
|
||||
const archive = {
|
||||
type: "archive_workspace_request",
|
||||
workspaceId,
|
||||
operation: { key: randomUUID() },
|
||||
};
|
||||
expect(await request(socket, archive)).toMatchObject({ error: null });
|
||||
expect(
|
||||
await request(socket, { type: "workspace.recovery.restore.request", workspaceId }),
|
||||
).toMatchObject({ error: null });
|
||||
expect(await request(socket, archive)).toMatchObject({ error: null });
|
||||
expect(
|
||||
await request(socket, { type: "workspace.recovery.inspect.request", workspaceId }),
|
||||
).toMatchObject({ state: { kind: "unavailable", reason: "workspace_not_archived" } });
|
||||
} finally {
|
||||
socket.close();
|
||||
await host.close();
|
||||
}
|
||||
}, 30_000);
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import { AgentRequestError, type AgentRequests } from "./agent/requests/index.js";
|
||||
import type { AgentRequests } from "./agent/requests/index.js";
|
||||
import equal from "fast-deep-equal";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
||||
@@ -464,10 +464,7 @@ export interface SessionOptions {
|
||||
worktreesRoot?: string;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentRequests: Pick<
|
||||
AgentRequests,
|
||||
"create" | "send" | "run" | "control" | "cancelOperation" | "inspectOperation"
|
||||
>;
|
||||
agentRequests: Pick<AgentRequests, "create" | "send">;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
directorySync?: DirectorySyncService;
|
||||
@@ -757,10 +754,7 @@ export class Session {
|
||||
private readonly daemonSession: DaemonSession;
|
||||
private readonly hubExecutionController: HubExecutionController | null;
|
||||
private readonly workspaceScripts: WorkspaceScriptsService;
|
||||
private readonly agentRequests: Pick<
|
||||
AgentRequests,
|
||||
"create" | "send" | "run" | "control" | "cancelOperation" | "inspectOperation"
|
||||
>;
|
||||
private readonly agentRequests: Pick<AgentRequests, "create" | "send">;
|
||||
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
||||
|
||||
constructor(options: SessionOptions) {
|
||||
@@ -2008,7 +2002,6 @@ export class Session {
|
||||
this.dispatchAgentRelationshipMessage(msg) ??
|
||||
this.dispatchAgentTimelineMessage(msg, source) ??
|
||||
this.dispatchHubExecutionMessage(msg) ??
|
||||
this.dispatchAgentRequestMessage(msg) ??
|
||||
this.dispatchAgentLifecycleMessage(msg) ??
|
||||
this.dispatchAgentConfigMessage(msg) ??
|
||||
this.dispatchCheckoutMessage(msg) ??
|
||||
@@ -2392,27 +2385,6 @@ export class Session {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private dispatchAgentRequestMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "agent.requests.inspect.request":
|
||||
return this.agentRequests.inspectOperation(msg.key, msg.creationKey).then((result) => {
|
||||
return this.emit({
|
||||
type: "agent.requests.inspect.response",
|
||||
payload: { requestId: msg.requestId, ...result },
|
||||
});
|
||||
});
|
||||
case "agent.requests.cancel.request":
|
||||
return this.agentRequests.cancelOperation(msg.key, msg.creationKey).then((result) => {
|
||||
return this.emit({
|
||||
type: "agent.requests.cancel.response",
|
||||
payload: { requestId: msg.requestId, ...result },
|
||||
});
|
||||
});
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "fetch_agents_request":
|
||||
@@ -2436,15 +2408,11 @@ export class Session {
|
||||
case "project.icon.set.request":
|
||||
return this.handleProjectIconSetRequest(msg);
|
||||
case "send_agent_message_request":
|
||||
return this.agentRequests.run(msg.operation, (signal) =>
|
||||
this.handleSendAgentMessageRequest(msg, signal),
|
||||
);
|
||||
return this.handleSendAgentMessageRequest(msg);
|
||||
case "wait_for_finish_request":
|
||||
return this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs);
|
||||
case "create_agent_request":
|
||||
return this.agentRequests.run(msg.operation, (signal) =>
|
||||
this.handleCreateAgentRequest(msg, signal),
|
||||
);
|
||||
return this.handleCreateAgentRequest(msg);
|
||||
case "resume_agent_request":
|
||||
return this.handleResumeAgentRequest(msg);
|
||||
case "import_agent_request":
|
||||
@@ -2452,7 +2420,7 @@ export class Session {
|
||||
case "refresh_agent_request":
|
||||
return this.handleRefreshAgentRequest(msg);
|
||||
case "cancel_agent_request":
|
||||
return this.handleCancelAgentRequest(msg.agentId, msg.requestId, msg.operation);
|
||||
return this.handleCancelAgentRequest(msg.agentId, msg.requestId);
|
||||
case "agent_permission_response":
|
||||
return this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response);
|
||||
case "clear_agent_attention":
|
||||
@@ -2656,6 +2624,11 @@ export class Session {
|
||||
source?: object,
|
||||
): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "fs.content.search.request":
|
||||
return this.workspaceFilesSession.handleContentSearch(msg);
|
||||
case "fs.content.cancel.request":
|
||||
this.workspaceFilesSession.handleContentCancel(msg);
|
||||
return;
|
||||
case "file_explorer_request":
|
||||
return this.workspaceFilesSession.handleFileExplorerRequest(msg, source);
|
||||
case "fs.file.subscribe.request":
|
||||
@@ -2692,9 +2665,7 @@ export class Session {
|
||||
case "workspace.recovery.inspect.request":
|
||||
return this.handleWorkspaceRecoveryInspectRequest(msg);
|
||||
case "workspace.recovery.restore.request":
|
||||
return this.agentRequests.run(msg.operation, (signal) =>
|
||||
this.handleWorkspaceRecoveryRestoreRequest(msg, signal),
|
||||
);
|
||||
return this.handleWorkspaceRecoveryRestoreRequest(msg);
|
||||
case "workspace.clear_attention.request":
|
||||
return this.handleWorkspaceClearAttentionRequest(msg);
|
||||
case "workspace.mark_unread.request":
|
||||
@@ -3515,10 +3486,9 @@ export class Session {
|
||||
|
||||
private async handleWorkspaceRecoveryRestoreRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.recovery.restore.request" }>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.restoreWorkspaceAndEmit(request.workspaceId, signal);
|
||||
await this.restoreWorkspaceAndEmit(request.workspaceId);
|
||||
this.emit({
|
||||
type: "workspace.recovery.restore.response",
|
||||
payload: {
|
||||
@@ -3603,20 +3573,14 @@ export class Session {
|
||||
/**
|
||||
* Handle create agent request
|
||||
*/
|
||||
private async handleCreateAgentRequest(
|
||||
msg: CreateAgentRequestMessage,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
|
||||
try {
|
||||
if (msg.operation && msg.idempotencyKey === undefined) {
|
||||
throw new Error("Cancelable creation requires an idempotencyKey");
|
||||
}
|
||||
let agent: AgentSnapshotPayload;
|
||||
if (msg.idempotencyKey !== undefined) {
|
||||
if (msg.initialPrompt !== undefined) {
|
||||
throw new Error("Idempotent creation requires sending the initial prompt separately");
|
||||
}
|
||||
const { requestId: _requestId, idempotencyKey, operation: _operation, ...request } = msg;
|
||||
const { requestId: _requestId, idempotencyKey, ...request } = msg;
|
||||
const id = await this.agentRequests.create({
|
||||
key: idempotencyKey,
|
||||
request,
|
||||
@@ -3624,14 +3588,14 @@ export class Session {
|
||||
this.agentManager.getAgent(agentId) != null ||
|
||||
(await this.agentStorage.get(agentId)) !== null,
|
||||
create: async (agentId) => {
|
||||
await this.createSessionAgent(msg, agentId, signal);
|
||||
await this.createSessionAgent(msg, agentId);
|
||||
},
|
||||
});
|
||||
const record = await this.agentStorage.get(id);
|
||||
if (!record) throw new Error("Previously created agent no longer exists");
|
||||
agent = this.buildStoredAgentPayload(record);
|
||||
} else {
|
||||
agent = await this.createSessionAgent(msg, undefined, signal);
|
||||
agent = await this.createSessionAgent(msg);
|
||||
}
|
||||
this.emit({
|
||||
type: "status",
|
||||
@@ -3666,23 +3630,9 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupCanceledAgentCreate(
|
||||
agentId: string | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
if (!signal?.aborted || !agentId) return agentId;
|
||||
beginAgentDeleteIfSupported(this.agentStorage, agentId);
|
||||
await this.agentManager.closeAgent(agentId);
|
||||
await this.agentManager.flush();
|
||||
await this.agentStorage.remove(agentId);
|
||||
await this.agentManager.deleteAgentState(agentId);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async createSessionAgent(
|
||||
msg: CreateAgentRequestMessage,
|
||||
agentId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentSnapshotPayload> {
|
||||
const {
|
||||
config,
|
||||
@@ -3731,7 +3681,6 @@ export class Session {
|
||||
hasLegacyGitOptions: Boolean(git),
|
||||
});
|
||||
createdWorktreeForCleanup = createdWorktree;
|
||||
signal?.throwIfAborted();
|
||||
const resolvedIntent = await this.resolveSessionCreateAgentIntent({
|
||||
request: msg,
|
||||
createdWorktree,
|
||||
@@ -3753,7 +3702,6 @@ export class Session {
|
||||
},
|
||||
{
|
||||
kind: "session",
|
||||
signal,
|
||||
agentId,
|
||||
config: resolvedIntent.config,
|
||||
workspaceId: resolvedIntent.intent.workspaceId,
|
||||
@@ -3773,7 +3721,6 @@ export class Session {
|
||||
},
|
||||
);
|
||||
createdAgentId = snapshot.id;
|
||||
signal?.throwIfAborted();
|
||||
await this.agentUpdates.forwardLiveAgent(snapshot);
|
||||
if (resolvedIntent.createdDirectoryWorkspace && trimmedPrompt) {
|
||||
this.workspaceAutoName.scheduleForDirectory(
|
||||
@@ -3796,7 +3743,6 @@ export class Session {
|
||||
);
|
||||
return this.buildAgentPayload(liveSnapshot);
|
||||
} catch (error) {
|
||||
createdAgentId = await this.cleanupCanceledAgentCreate(createdAgentId, signal);
|
||||
await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({
|
||||
createdWorktree: createdWorktreeForCleanup,
|
||||
createdAgentId,
|
||||
@@ -4090,19 +4036,13 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCancelAgentRequest(
|
||||
agentId: string,
|
||||
requestId?: string,
|
||||
operation?: { key: string; deadlineAt?: string },
|
||||
): Promise<void> {
|
||||
private async handleCancelAgentRequest(agentId: string, requestId?: string): Promise<void> {
|
||||
this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`);
|
||||
|
||||
try {
|
||||
await this.agentRequests.control(operation, { action: "interrupt", agentId }, () =>
|
||||
cancelAgentRunCommand(
|
||||
{ agentManager: this.agentManager, logger: this.sessionLogger },
|
||||
agentId,
|
||||
),
|
||||
await cancelAgentRunCommand(
|
||||
{ agentManager: this.agentManager, logger: this.sessionLogger },
|
||||
agentId,
|
||||
);
|
||||
if (requestId) {
|
||||
const agent = this.agentManager.getAgent(agentId);
|
||||
@@ -5334,8 +5274,8 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
private async restoreWorkspaceAndEmit(workspaceId: string, signal?: AbortSignal): Promise<void> {
|
||||
await this.workspaceRecovery.restore(workspaceId, signal);
|
||||
private async restoreWorkspaceAndEmit(workspaceId: string): Promise<void> {
|
||||
await this.workspaceRecovery.restore(workspaceId);
|
||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!workspace) {
|
||||
throw new Error(`Recovered workspace record not found: ${workspaceId}`);
|
||||
@@ -6899,39 +6839,34 @@ export class Session {
|
||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
||||
}
|
||||
|
||||
await this.agentRequests.control(
|
||||
request.operation,
|
||||
{ action: "archive", workspaceId: request.workspaceId },
|
||||
() =>
|
||||
archiveByScope(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
paseoWorktreesBaseRoot: this.worktreesRoot,
|
||||
github: this.github,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
|
||||
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
||||
this.markWorkspaceArchiving(workspaceIds, archivingAt),
|
||||
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
|
||||
assertWorkspaceAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
||||
stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId),
|
||||
sessionLogger: this.sessionLogger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: existing.workspaceId },
|
||||
requestId: request.requestId,
|
||||
},
|
||||
),
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
paseoWorktreesBaseRoot: this.worktreesRoot,
|
||||
github: this.github,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
|
||||
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
||||
this.markWorkspaceArchiving(workspaceIds, archivingAt),
|
||||
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
|
||||
assertWorkspaceAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
||||
stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId),
|
||||
sessionLogger: this.sessionLogger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: existing.workspaceId },
|
||||
requestId: request.requestId,
|
||||
},
|
||||
);
|
||||
|
||||
const archivedWorkspace = await this.workspaceRegistry.get(request.workspaceId);
|
||||
@@ -7639,7 +7574,6 @@ export class Session {
|
||||
|
||||
private async handleSendAgentMessageRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "send_agent_message_request" }>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const resolved = await this.resolveAgentIdentifier(msg.agentId);
|
||||
if (!resolved.ok) {
|
||||
@@ -7655,21 +7589,7 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancellation: Promise<unknown> | undefined;
|
||||
const abort = () => {
|
||||
if (this.agentManager.getAgent(resolved.agentId)) {
|
||||
cancellation ??= this.agentManager.cancelAgentRun(resolved.agentId).then((result) => {
|
||||
if (result.status === "refused")
|
||||
throw new AgentRequestError("agent_request_outcome_unknown");
|
||||
return result;
|
||||
});
|
||||
void cancellation.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const agentId = resolved.agentId;
|
||||
|
||||
const prompt = buildAgentPrompt(msg.text, msg.images, msg.attachments);
|
||||
@@ -7683,11 +7603,9 @@ export class Session {
|
||||
"agent.session.send_agent_message",
|
||||
);
|
||||
const send = async () => {
|
||||
signal?.throwIfAborted();
|
||||
const result = await sendPromptToAgent({
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
signal,
|
||||
agentId,
|
||||
prompt,
|
||||
messageId: msg.messageId,
|
||||
@@ -7695,12 +7613,8 @@ export class Session {
|
||||
clearPendingPermissions: true,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
await this.agentManager.cancelAgentRun(agentId);
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
if (result.disposition === "turn_started") {
|
||||
await waitForAgentRunStartWithTimeout(this.agentManager, agentId, signal);
|
||||
await waitForAgentRunStartWithTimeout(this.agentManager, agentId);
|
||||
}
|
||||
};
|
||||
if (msg.messageId) {
|
||||
@@ -7741,9 +7655,6 @@ export class Session {
|
||||
error: errorToFriendlyMessage(error),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
await cancellation;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AgentRequests } from "./agent/requests/index.js";
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
@@ -933,126 +932,6 @@ test("client heartbeat clears attention for the focused terminal", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("expiry after agent registration removes its durable record before creation can retry", async () => {
|
||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-canceled-create-"));
|
||||
const logger = createTestLogger();
|
||||
const agentStorage = new AgentStorage(path.join(workdir, "agents"), logger);
|
||||
const agentManager = new AgentManager({
|
||||
clients: { codex: new CreateAgentTestClient() },
|
||||
registry: agentStorage,
|
||||
logger,
|
||||
});
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(workdir, "projects.json"),
|
||||
logger,
|
||||
);
|
||||
const workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(workdir, "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
let expire!: () => void;
|
||||
const requests = new AgentRequests(path.join(workdir, "requests"), {
|
||||
now: () => 0,
|
||||
schedule(callback) {
|
||||
expire = callback;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
let registeredId: string | undefined;
|
||||
const unsubscribe = agentManager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && registeredId === undefined) {
|
||||
registeredId = event.agent.id;
|
||||
expire();
|
||||
}
|
||||
},
|
||||
{ replayState: false },
|
||||
);
|
||||
try {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: requests,
|
||||
clientId: "test-client",
|
||||
serverId: "test-server",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
appVersion: null,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
logger: asSessionLogger(logger),
|
||||
downloadTokenStore: asDownloadTokenStore(),
|
||||
pushNotifications: asPushNotifications(),
|
||||
paseoHome: path.join(workdir, "paseo-home"),
|
||||
agentManager,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
scheduleService: asScheduleService(),
|
||||
checkoutDiffManager: asCheckoutDiffManager({
|
||||
subscribe: async () => ({
|
||||
initial: { cwd: workdir, files: [], error: null },
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
invalidateForge: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
}),
|
||||
workspaceGitService: createNoopWorkspaceGitService(),
|
||||
daemonConfigStore: asDaemonConfigStore({
|
||||
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
||||
onChange: () => () => {},
|
||||
}),
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
terminalManager: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "canceled",
|
||||
idempotencyKey: "conversation",
|
||||
operation: { key: "arrival", deadlineAt: "2026-09-09T00:00:00Z" },
|
||||
config: { provider: "codex", cwd: workdir },
|
||||
});
|
||||
await requests.cancelAndSettle("arrival");
|
||||
expect(registeredId).toEqual(expect.any(String));
|
||||
expect(agentManager.listAgents()).toEqual([]);
|
||||
expect(await agentStorage.list()).toEqual([]);
|
||||
expect(await requests.inspectOperation("arrival", "conversation")).toEqual({
|
||||
outcome: "settled",
|
||||
agentId: null,
|
||||
});
|
||||
unsubscribe();
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "retry",
|
||||
idempotencyKey: "conversation",
|
||||
operation: { key: "next", deadlineAt: "2026-09-09T00:00:00Z" },
|
||||
config: { provider: "codex", cwd: workdir },
|
||||
});
|
||||
expect(agentManager.listAgents()).toHaveLength(1);
|
||||
expect(
|
||||
emitted.filter(
|
||||
(message) => message.type === "status" && message.payload.status === "agent_created",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await Promise.all(agentManager.listAgents().map((agent) => agentManager.closeAgent(agent.id)));
|
||||
await agentManager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent_request keeps requested child cwd when grouped under an existing parent workspace", async () => {
|
||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-cwd-"));
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { searchWorkspaceContent } from "../../workspace-content-search/index.js";
|
||||
import type pino from "pino";
|
||||
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||
import {
|
||||
@@ -63,6 +64,7 @@ export interface WorkspaceFilesSessionOptions {
|
||||
* the whole concern.
|
||||
*/
|
||||
export class WorkspaceFilesSession {
|
||||
private readonly searches = new Map<string, AbortController>();
|
||||
private readonly host: WorkspaceFilesSessionHost;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly logger: pino.Logger;
|
||||
@@ -78,6 +80,50 @@ export class WorkspaceFilesSession {
|
||||
this.fileObserver = options.fileObserver ?? workspaceFileObserver;
|
||||
}
|
||||
|
||||
async handleContentSearch(
|
||||
request: Extract<SessionInboundMessage, { type: "fs.content.search.request" }>,
|
||||
): Promise<void> {
|
||||
if (this.searches.size >= 2 || this.searches.has(request.requestId)) {
|
||||
this.host.emit({
|
||||
type: "fs.content.search.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
result: {
|
||||
status: "error",
|
||||
code: "busy",
|
||||
message: "Another search is still stopping — retry",
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
this.searches.set(request.requestId, controller);
|
||||
try {
|
||||
const result = await searchWorkspaceContent({
|
||||
cwd: request.cwd,
|
||||
query: request.query,
|
||||
signal: controller.signal,
|
||||
});
|
||||
this.host.emit({
|
||||
type: "fs.content.search.response",
|
||||
payload: { requestId: request.requestId, result },
|
||||
});
|
||||
} finally {
|
||||
this.searches.delete(request.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
handleContentCancel(
|
||||
request: Extract<SessionInboundMessage, { type: "fs.content.cancel.request" }>,
|
||||
): void {
|
||||
this.searches.get(request.searchRequestId)?.abort();
|
||||
this.host.emit({
|
||||
type: "fs.content.cancel.response",
|
||||
payload: { requestId: request.requestId, searchRequestId: request.searchRequestId },
|
||||
});
|
||||
}
|
||||
|
||||
async handleFileSubscribeRequest(request: FileSubscribeRequest): Promise<void> {
|
||||
this.fileSubscriptions.get(request.subscriptionId)?.();
|
||||
try {
|
||||
@@ -214,13 +260,14 @@ export class WorkspaceFilesSession {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const controller of this.searches.values()) controller.abort();
|
||||
for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe();
|
||||
this.fileSubscriptions.clear();
|
||||
}
|
||||
|
||||
async handleFileExplorerRequest(request: FileExplorerRequest, source?: object): Promise<void> {
|
||||
const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request;
|
||||
const cwd = workspaceCwd.trim();
|
||||
const cwd = workspaceCwd;
|
||||
if (!cwd) {
|
||||
this.host.emit(
|
||||
{
|
||||
@@ -270,41 +317,45 @@ export class WorkspaceFilesSession {
|
||||
}
|
||||
}
|
||||
if (request.acceptBinary && this.host.hasBinaryChannel()) {
|
||||
await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
|
||||
await this.host.emitBinary(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId,
|
||||
metadata: {
|
||||
mime: file.mimeType,
|
||||
size: file.size,
|
||||
encoding: file.encoding,
|
||||
modifiedAt: file.modifiedAt,
|
||||
revision: file.revision,
|
||||
},
|
||||
}),
|
||||
source,
|
||||
);
|
||||
for await (const chunk of file.chunks) {
|
||||
await streamExplorerFile(
|
||||
{ root: cwd, relativePath: requestedPath, maxBytes: request.maxBytes },
|
||||
async (file) => {
|
||||
await this.host.emitBinary(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId,
|
||||
payload: chunk,
|
||||
metadata: {
|
||||
mime: file.mimeType,
|
||||
size: file.size,
|
||||
encoding: file.encoding,
|
||||
modifiedAt: file.modifiedAt,
|
||||
revision: file.revision,
|
||||
},
|
||||
}),
|
||||
source,
|
||||
);
|
||||
}
|
||||
await this.host.emitBinary(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileEnd,
|
||||
requestId,
|
||||
}),
|
||||
source,
|
||||
);
|
||||
});
|
||||
for await (const chunk of file.chunks) {
|
||||
await this.host.emitBinary(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId,
|
||||
payload: chunk,
|
||||
}),
|
||||
source,
|
||||
);
|
||||
}
|
||||
await this.host.emitBinary(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileEnd,
|
||||
requestId,
|
||||
}),
|
||||
source,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const file = await readExplorerFile({
|
||||
maxBytes: request.maxBytes,
|
||||
root: cwd,
|
||||
relativePath: requestedPath,
|
||||
});
|
||||
@@ -391,7 +442,7 @@ export class WorkspaceFilesSession {
|
||||
|
||||
async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise<void> {
|
||||
const { cwd: workspaceCwd, path: requestedPath, requestId } = request;
|
||||
const cwd = workspaceCwd.trim();
|
||||
const cwd = workspaceCwd;
|
||||
if (!cwd) {
|
||||
this.host.emit({
|
||||
type: "file_download_token_response",
|
||||
|
||||
-102
@@ -117,108 +117,6 @@ describe("workspace recovery", () => {
|
||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||
});
|
||||
|
||||
test("a repeated restore of an already active workspace succeeds without another unarchive", async () => {
|
||||
const workspace = createWorkspace({ archivedAt: null });
|
||||
const { service, unarchived } = createHarness({ workspace, directories: [workspace.cwd] });
|
||||
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
action: "unarchive",
|
||||
});
|
||||
expect(unarchived).toEqual([]);
|
||||
});
|
||||
|
||||
test("an active workspace with a missing directory cannot report a successful restore", async () => {
|
||||
const workspace = createWorkspace({ archivedAt: null });
|
||||
const { service, unarchived } = createHarness({ workspace });
|
||||
await expect(service.restore(workspace.workspaceId)).rejects.toThrow();
|
||||
expect(unarchived).toEqual([]);
|
||||
});
|
||||
|
||||
test("expiry after filesystem recreation rolls back the new checkout", async () => {
|
||||
const { tempDir, repoDir } = createGitRepository();
|
||||
const branch = "feature/canceled-restore";
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
const paseoHome = join(tempDir, "paseo-home");
|
||||
const worktreesRoot = join(tempDir, "worktrees");
|
||||
const created = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "canceled-restore",
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
});
|
||||
const worktreeRoot = realpathSync(created.worktreePath);
|
||||
rmSync(worktreeRoot, { recursive: true, force: true });
|
||||
const workspace = createWorkspace({
|
||||
cwd: worktreeRoot,
|
||||
branch,
|
||||
worktreeRoot,
|
||||
mainRepoRoot: repoDir,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
let unarchived = false;
|
||||
const service = createWorkspaceRecoveryService({
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async () => workspace,
|
||||
getProject: async () => createProject({ rootPath: repoDir }),
|
||||
isDirectory: async (target) => {
|
||||
const exists = existsSync(target) && statSync(target).isDirectory();
|
||||
if (target === worktreeRoot && exists) controller.abort(new Error("expired"));
|
||||
return exists;
|
||||
},
|
||||
unarchiveWorkspace: async () => {
|
||||
unarchived = true;
|
||||
},
|
||||
});
|
||||
await expect(service.restore(workspace.workspaceId, controller.signal)).rejects.toThrow(
|
||||
"expired",
|
||||
);
|
||||
expect(unarchived).toBe(false);
|
||||
expect(existsSync(worktreeRoot)).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}).toString(),
|
||||
).not.toContain(worktreeRoot);
|
||||
});
|
||||
|
||||
test("expiry during restore preparation cannot unarchive the workspace", async () => {
|
||||
const workspace = createWorkspace({ kind: "directory", branch: null });
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
let unarchived = false;
|
||||
const service = createWorkspaceRecoveryService({
|
||||
paseoHome: "/paseo-home",
|
||||
worktreesRoot: "/worktrees",
|
||||
getWorkspace: async () => workspace,
|
||||
getProject: async () => createProject(),
|
||||
isDirectory: async () => {
|
||||
entered();
|
||||
await gate;
|
||||
return true;
|
||||
},
|
||||
unarchiveWorkspace: async () => {
|
||||
unarchived = true;
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const restoring = service.restore(workspace.workspaceId, controller.signal);
|
||||
await started;
|
||||
controller.abort(new Error("expired"));
|
||||
release();
|
||||
await expect(restoring).rejects.toThrow("expired");
|
||||
expect(unarchived).toBe(false);
|
||||
});
|
||||
|
||||
test("does not offer recovery for a missing non-worktree directory", async () => {
|
||||
const workspace = createWorkspace({ kind: "directory", branch: null });
|
||||
const { service } = createHarness({ workspace });
|
||||
|
||||
+3
-22
@@ -40,10 +40,7 @@ export type WorkspaceRecoveryState =
|
||||
|
||||
export interface WorkspaceRecoveryService {
|
||||
inspect(workspaceId: string): Promise<WorkspaceRecoveryState>;
|
||||
restore(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>;
|
||||
restore(workspaceId: string): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>;
|
||||
}
|
||||
|
||||
type RecoveryPlan =
|
||||
@@ -81,7 +78,7 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
message: "This workspace is no longer known to the host.",
|
||||
};
|
||||
}
|
||||
if (!workspace.archivedAt && (await deps.isDirectory(workspace.cwd))) {
|
||||
if (!workspace.archivedAt) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
@@ -90,14 +87,6 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
};
|
||||
}
|
||||
|
||||
if (!workspace.archivedAt) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
reason: "workspace_directory_missing",
|
||||
message: "The workspace directory no longer exists.",
|
||||
};
|
||||
}
|
||||
const project = await deps.getProject(workspace.projectId);
|
||||
if (!project) {
|
||||
return {
|
||||
@@ -151,20 +140,15 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
|
||||
async function restore(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> {
|
||||
signal?.throwIfAborted();
|
||||
const resolved = await resolveRecovery(workspaceId);
|
||||
signal?.throwIfAborted();
|
||||
if (resolved.kind === "unavailable") {
|
||||
if (resolved.reason === "workspace_not_archived") return { workspaceId, action: "unarchive" };
|
||||
throw new Error(resolved.message);
|
||||
}
|
||||
|
||||
if (resolved.kind === "restore") {
|
||||
await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot, signal);
|
||||
await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
await deps.unarchiveWorkspace(resolved.workspace);
|
||||
return { workspaceId, action: resolved.kind };
|
||||
}
|
||||
@@ -172,7 +156,6 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
async function recreateArchivedWorktree(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
sourceRepoRoot: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const branch = workspace.branch;
|
||||
if (!branch) {
|
||||
@@ -217,7 +200,6 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
}
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath: previousWorktreePath,
|
||||
workspaceCwd: workspace.cwd,
|
||||
@@ -235,7 +217,6 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
message: `Selected project directory is missing from the restored worktree: ${recreatedWorkspacePath}`,
|
||||
});
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
} catch (error) {
|
||||
return rollbackCreatedPaseoWorktree(
|
||||
{
|
||||
|
||||
@@ -58,14 +58,11 @@ interface FakeAgentSessionOptions {
|
||||
sessionId?: string;
|
||||
memoryMarker?: string | null;
|
||||
closeSession?: () => Promise<void>;
|
||||
beforeStartTurn?: () => Promise<void>;
|
||||
onStartTurn?: (prompt: AgentPromptInput) => void;
|
||||
}
|
||||
|
||||
export interface TestAgentClientOptions {
|
||||
beforeCreateSession?: () => Promise<void>;
|
||||
closeSession?: () => Promise<void>;
|
||||
beforeStartTurn?: () => Promise<void>;
|
||||
onStartTurn?: (prompt: AgentPromptInput) => void;
|
||||
supportsMcpServers?: boolean;
|
||||
}
|
||||
@@ -339,7 +336,6 @@ class FakeAgentSession implements AgentSession {
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
|
||||
private readonly closeSession: (() => Promise<void>) | undefined;
|
||||
private readonly beforeStartTurn: (() => Promise<void>) | undefined;
|
||||
private readonly onStartTurn: ((prompt: AgentPromptInput) => void) | undefined;
|
||||
|
||||
constructor(options: FakeAgentSessionOptions) {
|
||||
@@ -353,7 +349,6 @@ class FakeAgentSession implements AgentSession {
|
||||
this.memoryMarker = options.memoryMarker ?? null;
|
||||
this.closeSession = options.closeSession;
|
||||
this.onStartTurn = options.onStartTurn;
|
||||
this.beforeStartTurn = options.beforeStartTurn;
|
||||
this.historyPath = path.join(
|
||||
tmpdir(),
|
||||
"paseo-fake-provider-history",
|
||||
@@ -438,7 +433,6 @@ class FakeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
await this.beforeStartTurn?.();
|
||||
if (this.activeForegroundTurnId) {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
@@ -1208,14 +1202,12 @@ class FakeAgentClient implements AgentClient {
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
await this.options.beforeCreateSession?.();
|
||||
return new FakeAgentSession({
|
||||
providerName: this.provider,
|
||||
config: { ...config },
|
||||
supportsMcpServers: this.options.supportsMcpServers,
|
||||
closeSession: this.options.closeSession,
|
||||
onStartTurn: this.options.onStartTurn,
|
||||
beforeStartTurn: this.options.beforeStartTurn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1241,7 +1233,6 @@ class FakeAgentClient implements AgentClient {
|
||||
memoryMarker: typeof marker === "string" ? marker : null,
|
||||
closeSession: this.options.closeSession,
|
||||
onStartTurn: this.options.onStartTurn,
|
||||
beforeStartTurn: this.options.beforeStartTurn,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -286,12 +286,6 @@ export function createAgentRequestsStub(): SessionOptions["agentRequests"] {
|
||||
return agentId;
|
||||
},
|
||||
send: (input) => input.send(),
|
||||
run: (_startup, operation) => operation(),
|
||||
control: async (_context, _request, operation) => {
|
||||
await operation();
|
||||
},
|
||||
cancelOperation: async () => ({ agentId: null, outcome: "settled" }),
|
||||
inspectOperation: async () => ({ agentId: null, outcome: "settled" }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -650,7 +650,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
pluginRuntime?: SessionOptions["pluginRuntime"],
|
||||
orchestrationSkills?: SessionOptions["orchestrationSkills"],
|
||||
workspaceLabelService?: WorkspaceLabelService,
|
||||
agentRequests?: AgentRequests,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.workspaceSetupRuntime = workspaceSetupRuntime;
|
||||
@@ -669,7 +668,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.orchestrationSkills = orchestrationSkills;
|
||||
this.agentManager = agentManager;
|
||||
this.agentStorage = agentStorage;
|
||||
this.agentRequests = agentRequests ?? new AgentRequests(join(paseoHome, "agent-requests"));
|
||||
this.agentRequests = new AgentRequests(join(paseoHome, "agent-requests"));
|
||||
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
||||
this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry();
|
||||
this.workspaceLabelService = workspaceLabelService ?? null;
|
||||
@@ -1634,7 +1633,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
|
||||
features: {
|
||||
agentRequestReceipts: true,
|
||||
agentRequestCancellation: true,
|
||||
hubAgentRpc: true,
|
||||
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
|
||||
directorySync: true,
|
||||
@@ -1714,6 +1712,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceRecovery: true,
|
||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||
workspaceFileEditing: true,
|
||||
workspaceContentSearch: true,
|
||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||
providerUsageList: true,
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { searchWorkspaceContent } from "./index.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
async function workspace(files: Record<string, string>) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-content-search-"));
|
||||
roots.push(root);
|
||||
for (const [name, text] of Object.entries(files)) await writeFile(path.join(root, name), text);
|
||||
return root;
|
||||
}
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("searches saved untracked text literally, ignoring case and ignore rules in a non-Git workspace", async () => {
|
||||
const cwd = await workspace({
|
||||
"a.ts": "é🙂 NEEDLE needle\r\n",
|
||||
"b.ts": "a.b AXB",
|
||||
".hidden": "Needle",
|
||||
".gitignore": "ignored\n",
|
||||
ignored: "needle",
|
||||
});
|
||||
const result = await searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(result).toEqual({
|
||||
status: "ok",
|
||||
limited: false,
|
||||
maxFileBytes: 1_048_576,
|
||||
matches: [
|
||||
{
|
||||
path: ".hidden",
|
||||
line: 1,
|
||||
columnStart: 1,
|
||||
columnEnd: 7,
|
||||
text: "Needle",
|
||||
snippet: "Needle",
|
||||
snippetMatchStart: 0,
|
||||
snippetMatchEnd: 6,
|
||||
},
|
||||
{
|
||||
path: "a.ts",
|
||||
line: 1,
|
||||
columnStart: 5,
|
||||
columnEnd: 11,
|
||||
text: "NEEDLE",
|
||||
snippet: "é🙂 NEEDLE needle",
|
||||
snippetMatchStart: 4,
|
||||
snippetMatchEnd: 10,
|
||||
},
|
||||
{
|
||||
path: "a.ts",
|
||||
line: 1,
|
||||
columnStart: 12,
|
||||
columnEnd: 18,
|
||||
text: "needle",
|
||||
snippet: "é🙂 NEEDLE needle",
|
||||
snippetMatchStart: 11,
|
||||
snippetMatchEnd: 17,
|
||||
},
|
||||
],
|
||||
});
|
||||
const literal = await searchWorkspaceContent({ cwd, query: "a.b" });
|
||||
expect(literal.status === "ok" && literal.matches.map((m) => m.text)).toEqual(["a.b"]);
|
||||
});
|
||||
|
||||
test("reports missing rg, empty search, cancellation and file/result limits", async () => {
|
||||
const cwd = await workspace({
|
||||
"many.txt": "needle\n".repeat(250),
|
||||
oversized: "needle" + " ".repeat(1_048_576),
|
||||
});
|
||||
const result = await searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(result.status === "ok" && [result.matches.length, result.limited]).toEqual([200, true]);
|
||||
expect(await searchWorkspaceContent({ cwd, query: "" })).toEqual({
|
||||
status: "ok",
|
||||
matches: [],
|
||||
limited: false,
|
||||
maxFileBytes: 1_048_576,
|
||||
});
|
||||
expect(await searchWorkspaceContent({ cwd, query: "x" }, { env: { PATH: "" } })).toMatchObject({
|
||||
status: "error",
|
||||
code: "missing_rg",
|
||||
});
|
||||
expect(
|
||||
await searchWorkspaceContent({ cwd, query: "x", signal: AbortSignal.abort() }),
|
||||
).toMatchObject({ status: "error", code: "cancelled" });
|
||||
});
|
||||
|
||||
test("preserves Unicode case-fold matches and BOM offsets, rejects binary files and bounds long records", async () => {
|
||||
const cwd = await workspace({
|
||||
unicode: "\uFEFFé🙂 K k K ſ S s σ Σ ς\r\n",
|
||||
binary: "needle\0binary",
|
||||
long: "x".repeat(1_040_000) + "needle",
|
||||
});
|
||||
const kelvin = await searchWorkspaceContent({ cwd, query: "k" });
|
||||
expect(kelvin.status === "ok" && kelvin.matches.map((m) => [m.text, m.columnStart])).toEqual([
|
||||
["K", 5],
|
||||
["k", 7],
|
||||
["K", 9],
|
||||
]);
|
||||
const sigma = await searchWorkspaceContent({ cwd, query: "σ" });
|
||||
expect(sigma.status === "ok" && sigma.matches.map((m) => m.text)).toEqual(["σ", "Σ", "ς"]);
|
||||
const binary = await searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(binary.status === "ok" && binary.matches.map((m) => m.path)).toEqual(["long"]);
|
||||
});
|
||||
|
||||
test("bounds escaped output and reports timeouts and invalid single-line queries", async () => {
|
||||
const cwd = await workspace({ escaped: "\t".repeat(600_000) + "needle" });
|
||||
expect(await searchWorkspaceContent({ cwd, query: "needle" })).toMatchObject({
|
||||
status: "ok",
|
||||
limited: true,
|
||||
});
|
||||
expect(await searchWorkspaceContent({ cwd, query: "x\ny" })).toMatchObject({
|
||||
status: "error",
|
||||
code: "invalid_query",
|
||||
});
|
||||
expect(await searchWorkspaceContent({ cwd, query: "needle" }, { timeoutMs: 0 })).toMatchObject({
|
||||
status: "error",
|
||||
code: "timeout",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses source lines across CR-only and mixed separators, including unmatched preceding lines", async () => {
|
||||
const cwd = await workspace({
|
||||
"cr.txt": "first\rneedle",
|
||||
"mixed.txt": "unmatched\rprefix\nfirst\r🙂 NEEDLE\r\nend",
|
||||
});
|
||||
const result = await searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(
|
||||
result.status === "ok" &&
|
||||
result.matches.map(({ path: filePath, line, columnStart, columnEnd, text }) => ({
|
||||
path: filePath,
|
||||
line,
|
||||
columnStart,
|
||||
columnEnd,
|
||||
text,
|
||||
})),
|
||||
).toEqual([
|
||||
{ path: "cr.txt", line: 2, columnStart: 1, columnEnd: 7, text: "needle" },
|
||||
{ path: "mixed.txt", line: 4, columnStart: 4, columnEnd: 10, text: "NEEDLE" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("validates UTF-8 beyond the matching record and preserves an embedded BOM character", async () => {
|
||||
const cwd = await workspace({ "valid.txt": "prefix \uFEFFneedle" });
|
||||
await writeFile(
|
||||
path.join(cwd, "invalid.txt"),
|
||||
Buffer.from([110, 101, 101, 100, 108, 101, 10, 255]),
|
||||
);
|
||||
const invalid = await searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(invalid.status === "ok" && invalid.matches.map((match) => match.path)).toEqual([
|
||||
"valid.txt",
|
||||
]);
|
||||
const embedded = await searchWorkspaceContent({ cwd, query: "\uFEFFneedle" });
|
||||
expect(
|
||||
embedded.status === "ok" && embedded.matches.map((match) => [match.text, match.columnStart]),
|
||||
).toEqual([["\uFEFFneedle", 8]]);
|
||||
});
|
||||
|
||||
test("states the per-file ceiling so a search that skipped an oversized file is not reported as complete", async () => {
|
||||
const cwd = await workspace({ "large.txt": `needle${" ".repeat(1_048_576)}` });
|
||||
expect(await searchWorkspaceContent({ cwd, query: "needle" })).toEqual({
|
||||
status: "ok",
|
||||
matches: [],
|
||||
limited: false,
|
||||
maxFileBytes: 1_048_576,
|
||||
});
|
||||
await writeFile(path.join(cwd, "small.txt"), "needle");
|
||||
const withSmall = await searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(
|
||||
withSmall.status === "ok" && [withSmall.matches.map((m) => m.path), withSmall.maxFileBytes],
|
||||
).toEqual([["small.txt"], 1_048_576]);
|
||||
});
|
||||
|
||||
// A backslash is an ordinary character in a Unix file name but a separator on Windows, so this
|
||||
// file name only exists on POSIX. Windows separator handling is covered by toWorkspaceRelativePath,
|
||||
// which runs on every platform.
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"keeps a literal backslash in a saved file name exactly as the host reported it",
|
||||
async () => {
|
||||
const cwd = await workspace({ "a\\b.txt": "needle" });
|
||||
const result = await searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(result.status === "ok" && result.matches.map((match) => match.path)).toEqual([
|
||||
"a\\b.txt",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test("converts coordinates in one pass, so a high-line-count file stays inside the budget", async () => {
|
||||
// A supported saved shape: a million empty lines, then 200 literal matches. Converting each
|
||||
// occurrence by decoding and splitting everything before it blocked for ~5.9s and answered "ok"
|
||||
// after the five-second budget had already passed.
|
||||
const timeoutMs = 5000;
|
||||
const cwd = await workspace({
|
||||
"sparse.txt": `${"\n".repeat(1_000_000)}${"needle\n".repeat(200)}`,
|
||||
});
|
||||
const started = Date.now();
|
||||
const result = await searchWorkspaceContent({ cwd, query: "needle" }, { timeoutMs });
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
expect(result.status === "ok" && result.matches.length).toBe(200);
|
||||
expect(result.status === "ok" && result.matches[0]).toMatchObject({
|
||||
path: "sparse.txt",
|
||||
line: 1_000_001,
|
||||
columnStart: 1,
|
||||
columnEnd: 7,
|
||||
text: "needle",
|
||||
snippet: "needle",
|
||||
});
|
||||
expect(result.status === "ok" && result.matches[199]).toMatchObject({ line: 1_000_200 });
|
||||
// A successful answer must arrive inside the budget it claims to honour.
|
||||
expect(elapsed, `succeeded after ${elapsed}ms against a ${timeoutMs}ms budget`).toBeLessThan(
|
||||
timeoutMs,
|
||||
);
|
||||
// And with room to spare: the conversion is meant to be proportional to the file, not to the
|
||||
// file times its occurrences.
|
||||
expect(elapsed, `conversion took ${elapsed}ms`).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test("stops converting when the deadline passes instead of answering late", async () => {
|
||||
const cwd = await workspace({ "many.txt": `${"needle\n".repeat(200)}` });
|
||||
expect(await searchWorkspaceContent({ cwd, query: "needle" }, { timeoutMs: 0 })).toMatchObject({
|
||||
status: "error",
|
||||
code: "timeout",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { sep } from "node:path";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import type {
|
||||
WorkspaceContentMatch,
|
||||
WorkspaceContentSearchResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { readExplorerFileBytes } from "../file-explorer/service.js";
|
||||
import { spawnProcess } from "../../utils/spawn.js";
|
||||
import { toWorkspaceRelativePath } from "../path-utils.js";
|
||||
|
||||
const MAX_RESULTS = 200;
|
||||
const MAX_FILE_BYTES = 1_048_576;
|
||||
const MAX_OUTPUT_BYTES = 4 * MAX_FILE_BYTES;
|
||||
|
||||
/** Searches the host's saved files; owns rg and every resource it allocates. */
|
||||
export async function searchWorkspaceContent(
|
||||
input: { cwd: string; query: string; signal?: AbortSignal },
|
||||
options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
|
||||
): Promise<WorkspaceContentSearchResult> {
|
||||
if (input.signal?.aborted) return failure("cancelled", "Search cancelled");
|
||||
if (
|
||||
Buffer.byteLength(input.query) > 1024 ||
|
||||
["\0", "\r", "\n"].some((character) => input.query.includes(character))
|
||||
) {
|
||||
return failure("invalid_query", "Search text must be one line, up to 1,024 bytes");
|
||||
}
|
||||
if (!input.query)
|
||||
return { status: "ok", matches: [], limited: false, maxFileBytes: MAX_FILE_BYTES };
|
||||
try {
|
||||
if (!(await stat(input.cwd)).isDirectory())
|
||||
return failure("unavailable", "Workspace directory is unavailable");
|
||||
} catch {
|
||||
return failure("unavailable", "Workspace directory is unavailable");
|
||||
}
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawnProcess(
|
||||
process.platform === "win32" ? "rg.exe" : "rg",
|
||||
[
|
||||
"--no-config",
|
||||
"--json",
|
||||
"--fixed-strings",
|
||||
"--ignore-case",
|
||||
"--hidden",
|
||||
"--no-require-git",
|
||||
"--glob",
|
||||
"!.git",
|
||||
"--max-filesize",
|
||||
String(MAX_FILE_BYTES),
|
||||
"--sort",
|
||||
"path",
|
||||
"--encoding",
|
||||
"none",
|
||||
"--",
|
||||
input.query,
|
||||
".",
|
||||
],
|
||||
{ cwd: input.cwd, shell: false, env: options.env, stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
} catch (cause) {
|
||||
return spawnFailure(cause);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const matches: RgMatch[] = [];
|
||||
let pending: RgMatch[] = [];
|
||||
let pendingPath = "";
|
||||
let buffer = Buffer.alloc(0);
|
||||
let outputBytes = 0;
|
||||
let stderr = "";
|
||||
let limited = false;
|
||||
let stopped = false;
|
||||
let error: WorkspaceContentSearchResult | null = null;
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
child.kill();
|
||||
killTimer = setTimeout(() => child.kill("SIGKILL"), 250);
|
||||
killTimer.unref();
|
||||
};
|
||||
const abort = () => {
|
||||
error = failure("cancelled", "Search cancelled");
|
||||
stop();
|
||||
};
|
||||
const timedOut = () => {
|
||||
error = failure("timeout", "Search timed out — refine your query or retry");
|
||||
stop();
|
||||
};
|
||||
// Reading and converting each matched file is synchronous work between ticks, so the timer
|
||||
// alone cannot end an operation that overruns inside it. Conversion checks the same deadline
|
||||
// directly, and the budget covers the whole operation rather than only its search.
|
||||
const deadline = Date.now() + (options.timeoutMs ?? 5000);
|
||||
const timeout = setTimeout(timedOut, options.timeoutMs ?? 5000);
|
||||
input.signal?.addEventListener("abort", abort, { once: true });
|
||||
if (input.signal?.aborted) abort();
|
||||
child.on("error", (cause) => {
|
||||
error ??= spawnFailure(cause);
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr = (stderr + chunk.toString("utf8")).slice(0, 8192);
|
||||
});
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
if (stopped) return;
|
||||
outputBytes += chunk.byteLength;
|
||||
if (outputBytes > MAX_OUTPUT_BYTES) {
|
||||
limited = true;
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
let end: number;
|
||||
while ((end = buffer.indexOf(10)) !== -1) {
|
||||
if (stopped) break;
|
||||
const record = buffer.subarray(0, end);
|
||||
buffer = buffer.subarray(end + 1);
|
||||
if (record.byteLength > MAX_FILE_BYTES) {
|
||||
limited = true;
|
||||
stop();
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const event = JSON.parse(record.toString("utf8")) as RgEvent;
|
||||
if (event.type === "begin") {
|
||||
pending = [];
|
||||
pendingPath = workspacePathFromRg(event.data.path?.text);
|
||||
} else if (event.type === "match" && pendingPath) {
|
||||
pending.push(
|
||||
...readMatches(
|
||||
event.data,
|
||||
pendingPath,
|
||||
MAX_RESULTS + 1 - matches.length - pending.length,
|
||||
),
|
||||
);
|
||||
} else if (event.type === "end") {
|
||||
// rg may emit matches before discovering a NUL byte later in the file.
|
||||
if (event.data.binary_offset == null) matches.push(...pending);
|
||||
pending = [];
|
||||
if (matches.length > MAX_RESULTS) {
|
||||
limited = true;
|
||||
stop();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
error = failure("unavailable", "Could not read ripgrep search results");
|
||||
stop();
|
||||
}
|
||||
}
|
||||
if (buffer.byteLength > MAX_FILE_BYTES) {
|
||||
limited = true;
|
||||
stop();
|
||||
}
|
||||
});
|
||||
const cleanup = () => {
|
||||
for (const timer of [timeout, killTimer]) clearTimeout(timer);
|
||||
input.signal?.removeEventListener("abort", abort);
|
||||
};
|
||||
child.once("close", async (code) => {
|
||||
if (!error && !limited && code !== 0 && code !== 1)
|
||||
error = failure("unavailable", stderr.trim() || "File search failed");
|
||||
let located: WorkspaceContentMatch[] = [];
|
||||
try {
|
||||
if (!error)
|
||||
located = await locateMatches(input.cwd, matches.slice(0, MAX_RESULTS), () => {
|
||||
if (!error && Date.now() >= deadline) timedOut();
|
||||
return !!error;
|
||||
});
|
||||
} catch {
|
||||
error ??= failure("unavailable", "Files changed while searching — retry");
|
||||
}
|
||||
cleanup();
|
||||
resolve(error ?? { status: "ok", matches: located, limited, maxFileBytes: MAX_FILE_BYTES });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** rg reports paths under the "." root using this host's separator; identities cross the wire
|
||||
* with "/" so a client never has to guess which character was a separator. */
|
||||
function workspacePathFromRg(reported: string | undefined): string {
|
||||
if (!reported) return "";
|
||||
const relative = reported.startsWith(`.${sep}`) ? reported.slice(2) : reported;
|
||||
return toWorkspaceRelativePath(relative);
|
||||
}
|
||||
|
||||
interface RgEvent {
|
||||
type: string;
|
||||
data: {
|
||||
path?: { text?: string };
|
||||
lines?: { text?: string };
|
||||
line_number?: number;
|
||||
submatches?: { start: number; end: number }[];
|
||||
binary_offset?: number | null;
|
||||
absolute_offset?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function readMatches(data: RgEvent["data"], path: string, remaining: number): RgMatch[] {
|
||||
if (!data.lines?.text || !data.line_number || remaining <= 0) return [];
|
||||
const raw = Buffer.from(data.lines.text);
|
||||
return (data.submatches ?? []).slice(0, remaining).map(({ start, end }) => ({
|
||||
path,
|
||||
byteOffset: (data.absolute_offset ?? 0) + start,
|
||||
text: raw.subarray(start, end).toString("utf8"),
|
||||
}));
|
||||
}
|
||||
|
||||
function failure(
|
||||
code: Extract<WorkspaceContentSearchResult, { status: "error" }>["code"],
|
||||
message: string,
|
||||
): WorkspaceContentSearchResult {
|
||||
return { status: "error", code, message };
|
||||
}
|
||||
function spawnFailure(cause: unknown): WorkspaceContentSearchResult {
|
||||
return cause instanceof Error && "code" in cause && cause.code === "ENOENT"
|
||||
? failure(
|
||||
"missing_rg",
|
||||
"Install ripgrep on this host and make rg available on its PATH, then retry",
|
||||
)
|
||||
: failure(
|
||||
"unavailable",
|
||||
cause instanceof Error ? cause.message : "Could not start file search",
|
||||
);
|
||||
}
|
||||
|
||||
interface RgMatch {
|
||||
path: string;
|
||||
text: string;
|
||||
byteOffset: number;
|
||||
}
|
||||
|
||||
const LINE_FEED = 0x0a;
|
||||
const CARRIAGE_RETURN = 0x0d;
|
||||
|
||||
/**
|
||||
* Walks a file's saved bytes once, handing out each occurrence's source line and UTF-16 column.
|
||||
*
|
||||
* rg counts LF records while source views count CR, CRLF and LF lines, so the offsets it reports
|
||||
* have to be translated. Doing that by decoding and splitting everything before each occurrence
|
||||
* costs the whole file per match: a million-line file with 200 matches blocked for about six
|
||||
* seconds and answered after its own deadline. Occurrences arrive in ascending offset order, so a
|
||||
* single forward cursor answers all of them, and each line is decoded once however many matches
|
||||
* it holds.
|
||||
*/
|
||||
function createLineReader(bytes: Uint8Array) {
|
||||
const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
||||
// A leading byte-order mark is not part of the first line's text and is not counted in its
|
||||
// columns. Stripping it once here keeps every later decode positional.
|
||||
const start = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0;
|
||||
let cursor = start;
|
||||
let line = 1;
|
||||
let lineStart = start;
|
||||
let lineEnd = -1;
|
||||
let lineText = "";
|
||||
let columnByte = 0;
|
||||
let column = 0;
|
||||
|
||||
function endOfLine(from: number): number {
|
||||
let index = from;
|
||||
while (index < bytes.length && bytes[index] !== LINE_FEED && bytes[index] !== CARRIAGE_RETURN) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
return function read(byteOffset: number) {
|
||||
while (cursor < byteOffset) {
|
||||
const byte = bytes[cursor];
|
||||
cursor += 1;
|
||||
if (byte === CARRIAGE_RETURN && bytes[cursor] === LINE_FEED && cursor < byteOffset) {
|
||||
cursor += 1;
|
||||
} else if (byte !== CARRIAGE_RETURN && byte !== LINE_FEED) {
|
||||
continue;
|
||||
}
|
||||
line += 1;
|
||||
lineStart = cursor;
|
||||
lineEnd = -1;
|
||||
}
|
||||
if (lineEnd === -1) {
|
||||
lineEnd = endOfLine(lineStart);
|
||||
lineText = decoder.decode(bytes.subarray(lineStart, lineEnd));
|
||||
columnByte = lineStart;
|
||||
column = 0;
|
||||
}
|
||||
// Matches on one line arrive in order, so the column advances from the previous one rather
|
||||
// than being recounted from the start of the line.
|
||||
if (byteOffset > columnByte) {
|
||||
column += decoder.decode(bytes.subarray(columnByte, byteOffset)).length;
|
||||
columnByte = byteOffset;
|
||||
}
|
||||
return { line, column, lineText };
|
||||
};
|
||||
}
|
||||
|
||||
/** Read each matched file once through the bounded/revision-checked producer to translate original
|
||||
* byte offsets. A file changed during the search must not manufacture an occurrence. */
|
||||
async function locateMatches(
|
||||
cwd: string,
|
||||
matches: RgMatch[],
|
||||
stopped: () => boolean,
|
||||
): Promise<WorkspaceContentMatch[]> {
|
||||
const located: WorkspaceContentMatch[] = [];
|
||||
let path = "";
|
||||
let bytes: Uint8Array = new Uint8Array();
|
||||
let valid = false;
|
||||
let read = createLineReader(bytes);
|
||||
for (const { byteOffset, ...match } of matches) {
|
||||
if (stopped()) break;
|
||||
if (path !== match.path) {
|
||||
path = match.path;
|
||||
const file = await readExplorerFileBytes({
|
||||
root: cwd,
|
||||
relativePath: path,
|
||||
maxBytes: MAX_FILE_BYTES,
|
||||
});
|
||||
bytes = file.bytes;
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
valid = !bytes.includes(0);
|
||||
} catch {
|
||||
valid = false;
|
||||
}
|
||||
read = createLineReader(bytes);
|
||||
}
|
||||
if (!valid) continue;
|
||||
const end = byteOffset + Buffer.byteLength(match.text);
|
||||
if (
|
||||
new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes.subarray(byteOffset, end)) !==
|
||||
match.text
|
||||
)
|
||||
continue;
|
||||
const { line, column, lineText } = read(byteOffset);
|
||||
const snippetStart = Math.max(0, column - 80);
|
||||
const snippet = lineText.slice(snippetStart, snippetStart + 240);
|
||||
located.push({
|
||||
...match,
|
||||
line,
|
||||
columnStart: column + 1,
|
||||
columnEnd: column + match.text.length + 1,
|
||||
snippet,
|
||||
snippetMatchStart: column - snippetStart,
|
||||
snippetMatchEnd: Math.min(column + match.text.length - snippetStart, snippet.length),
|
||||
});
|
||||
}
|
||||
return located;
|
||||
}
|
||||
@@ -192,7 +192,7 @@ An environment or dynamic named-agent expression must have a finite set of possi
|
||||
| --------------- | -------- | ---------------------------------------------------------------------------------------- |
|
||||
| `id` | yes | Unique within the workflow. |
|
||||
| `environment` | yes | Literal environment name or finite expression resolving to one. |
|
||||
| `max_runtime` | yes | Absolute step limit, including agent startup. |
|
||||
| `max_runtime` | yes | Step hard limit. |
|
||||
| `idle_timeout` | yes | Idle limit no longer than `max_runtime`. |
|
||||
| `agent` | yes | Named agent, finite expression selecting a named agent, or complete static inline agent. |
|
||||
| `prompt` | yes | Ordered `text` and `include` blocks. |
|
||||
@@ -203,12 +203,6 @@ An environment or dynamic named-agent expression must have a finite set of possi
|
||||
| `auto_archive` | no | Archive the agent after the step ends. |
|
||||
| `github` | no | Explicit GitHub authority for this step. |
|
||||
|
||||
`max_runtime` starts when Hub dispatches the step. Preparing a filesystem worktree, creating the provider agent, restoring a continuation workspace, and acknowledging the initial prompt all consume that budget. The deadline does not reset when startup finishes or activity arrives. The trigger's whole-run deadline can end the step sooner. Provider-specific startup failures can also end a step before its deadline.
|
||||
|
||||
`idle_timeout` starts alongside dispatch. An `initializing` or `running` agent status clears the idle deadline; an `idle` status starts it again. Meaningful agent activity refreshes an armed idle deadline. Connection heartbeats do not count as agent activity. Allow enough idle time for preparation before the daemon reports an active agent.
|
||||
|
||||
Continuation requires a daemon that supports cancelable agent requests. Update the daemon if Hub requests an update. When a deadline expires, Hub ends the step and reconciles daemon cleanup before another step can use the same conversation. If a daemon restart leaves the operation's outcome unknown, inspect the agent and workspace in Paseo before retrying with a new conversation key; Hub does not automatically repeat an ambiguous launch or prompt.
|
||||
|
||||
An inline agent is static and complete:
|
||||
|
||||
```yaml
|
||||
|
||||
Reference in New Issue
Block a user