mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
feat(app): search saved workspace contents from Command Center
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mkdtemp, rm, symlink } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test as base, expect } from "../support/fixtures";
|
||||
import { prepareContentSearch } from "../support/helpers/workspace-content-search";
|
||||
|
||||
const test = base.extend<{}, { searchHostPath: string }>({
|
||||
searchHostPath: [
|
||||
async ({ browserName }, provide) => {
|
||||
void browserName;
|
||||
const bin = await mkdtemp(path.join(os.tmpdir(), "search-host-path-"));
|
||||
await symlink(
|
||||
execFileSync("which", ["git"], { encoding: "utf8" }).trim(),
|
||||
path.join(bin, "git"),
|
||||
);
|
||||
await symlink(process.execPath, path.join(bin, "node"));
|
||||
try {
|
||||
await provide(bin);
|
||||
} finally {
|
||||
await rm(bin, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
e2eDaemonEnvironment: [
|
||||
async ({ searchHostPath }, provide) => {
|
||||
await provide({ PATH: searchHostPath });
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
||||
test("missing host ripgrep is actionable and Retry works after installation", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
searchHostPath,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await page.keyboard.press("Control+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("needle");
|
||||
await expect(
|
||||
page.getByText("Install ripgrep on this host and make rg available on its PATH, then retry", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByTestId("command-center-panel")
|
||||
.screenshot({ path: testInfo.outputPath("missing-rg.png") });
|
||||
await symlink(
|
||||
execFileSync("which", ["rg"], { encoding: "utf8" }).trim(),
|
||||
path.join(searchHostPath, "rg"),
|
||||
);
|
||||
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
|
||||
});
|
||||
@@ -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,136 @@
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
prepareContentSearch,
|
||||
searchContents,
|
||||
previewAndOpenSecondOccurrence,
|
||||
openCompactPreview,
|
||||
preserveDirtyBufferAndUndo,
|
||||
recoverChangedAndMissingPreview,
|
||||
searchContentsByTouch,
|
||||
} from "../support/helpers/workspace-content-search";
|
||||
|
||||
test.use({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
|
||||
});
|
||||
test("searches unopened saved contents, previews keyboard selection and opens the exact occurrence", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await searchContents(page, "needle");
|
||||
await previewAndOpenSecondOccurrence(page, testInfo.outputPath("content-search-wide.png"));
|
||||
await preserveDirtyBufferAndUndo(page, workspace);
|
||||
});
|
||||
test("compact search keeps list, preview and Open in the existing popup", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await searchContentsByTouch(page, "needle");
|
||||
await openCompactPreview(page, testInfo.outputPath("content-search-compact.png"));
|
||||
});
|
||||
|
||||
test("changed saved files, empty results and occurrence limits stay explicit", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await searchContents(page, "needle");
|
||||
await recoverChangedAndMissingPreview(
|
||||
page,
|
||||
workspace,
|
||||
testInfo.outputPath("content-search-limited.png"),
|
||||
);
|
||||
});
|
||||
|
||||
test("a host without content search advertises an update requirement without sending the new RPC", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
gate.setWorkspaceContentSearchStripped();
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await expect(
|
||||
page.getByText("Update this host to search file contents", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(
|
||||
page.getByText("Update this host to search file contents", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(gate.getClientRequestCount("fs.content.search.request")).toBe(0);
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(
|
||||
page.getByRole("textbox", {
|
||||
name: "Search commands, files, workspaces, and agents...",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Meta+p");
|
||||
await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
import { openLiteralSourceOccurrence } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("SVG image opening stays visual while a saved occurrence opens exact source", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await openLiteralSourceOccurrence(
|
||||
page,
|
||||
workspace,
|
||||
{
|
||||
path: "icon.svg",
|
||||
content: '<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"),
|
||||
);
|
||||
});
|
||||
@@ -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,153 @@
|
||||
import { unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { CreatedWorkspace } from "./with-workspace";
|
||||
|
||||
export async function prepareContentSearch(workspace: CreatedWorkspace) {
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-a.ts"),
|
||||
'export const greeting = "NEEDLE";\n',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-b.ts"),
|
||||
'const emoji = "é🙂 needle needle";\n',
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
}
|
||||
export async function searchContents(page: Page, query: string) {
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(query);
|
||||
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
|
||||
}
|
||||
export async function previewAndOpenSecondOccurrence(page: Page, screenshot: string) {
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("export const greeting");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("const emoji");
|
||||
await expect(input).toBeFocused();
|
||||
const inputBox = await input.boundingBox();
|
||||
const previewBox = await panel.getByTestId("file-source-editor").boundingBox();
|
||||
expect(inputBox!.y).toBeGreaterThan(previewBox!.y + previewBox!.height);
|
||||
await panel.screenshot({ path: screenshot });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("const emoji");
|
||||
// The source view uses a real CodeMirror document; select-all is unnecessary and would hide a bad jump.
|
||||
await expect(page.getByLabel("Line 1, column 26", { exact: true })).toBeVisible();
|
||||
}
|
||||
export async function openCompactPreview(page: Page, screenshot: string) {
|
||||
await page.getByRole("button", { name: /search-a.ts:1:26/ }).click();
|
||||
await expect(page.getByRole("button", { name: "Back to results", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("command-center-panel").getByTestId("file-source-editor"),
|
||||
).toContainText("export const greeting");
|
||||
await page.getByTestId("command-center-panel").screenshot({ path: screenshot });
|
||||
await page.getByRole("button", { name: "Open", exact: true }).click();
|
||||
await expect(page.getByTestId("command-center-panel")).toBeHidden();
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("export const greeting");
|
||||
}
|
||||
|
||||
export async function preserveDirtyBufferAndUndo(page: Page, workspace: CreatedWorkspace) {
|
||||
const source = page.getByTestId("file-source-editor").locator(".cm-content");
|
||||
await source.fill("const localUnsaved = 42;\n");
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-b.ts"),
|
||||
'const emoji = "é🙂 needle needle";\n// changed on disk\n',
|
||||
);
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await searchContents(page, "needle");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(
|
||||
page.getByTestId("command-center-panel").getByTestId("file-source-editor"),
|
||||
).toContainText("changed on disk");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(source).toContainText("localUnsaved");
|
||||
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toContainText(
|
||||
"saved match is no longer at this location",
|
||||
);
|
||||
await source.press("Control+z");
|
||||
await expect(source).toContainText('const emoji = "é🙂 needle needle";');
|
||||
await expect(source).not.toContainText("localUnsaved");
|
||||
}
|
||||
|
||||
export async function recoverChangedAndMissingPreview(
|
||||
page: Page,
|
||||
workspace: CreatedWorkspace,
|
||||
screenshot: string,
|
||||
) {
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await writeFile(path.join(workspace.repoPath, "search-b.ts"), "changed after search\n");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(panel.getByRole("status")).toContainText(
|
||||
"saved match is no longer at this location",
|
||||
);
|
||||
await unlink(path.join(workspace.repoPath, "search-a.ts"));
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-a.ts"),
|
||||
'export const greeting = "NEEDLE";\n',
|
||||
);
|
||||
await panel.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("NEEDLE");
|
||||
await input.fill("not-present-anywhere");
|
||||
await expect(panel.getByText("No matches", { exact: true })).toBeVisible();
|
||||
await writeFile(path.join(workspace.repoPath, "many.txt"), "needle\n".repeat(250));
|
||||
await input.fill("needle");
|
||||
await expect(panel.getByText(/Results limited/)).toBeVisible();
|
||||
await panel.screenshot({ path: screenshot });
|
||||
}
|
||||
|
||||
export async function searchContentsByTouch(page: Page, query: string) {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Search", exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", {
|
||||
name: "Search commands, files, workspaces, and agents...",
|
||||
exact: true,
|
||||
})
|
||||
.fill("Search file contents");
|
||||
await page.getByText("Search file contents", { exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill(query);
|
||||
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openLiteralSourceOccurrence(
|
||||
page: Page,
|
||||
workspace: CreatedWorkspace,
|
||||
input: { path: string; content: string; result: string; cursor?: string; imageFirst?: boolean },
|
||||
screenshot: string,
|
||||
) {
|
||||
await writeFile(path.join(workspace.repoPath, input.path), input.content);
|
||||
await writeFile(path.join(workspace.repoPath, "leading.txt"), "different file");
|
||||
await workspace.navigateTo();
|
||||
if (input.imageFirst) {
|
||||
await page.keyboard.press("Meta+p");
|
||||
await page.getByRole("textbox", { name: "Search files...", exact: true }).fill(input.path);
|
||||
await page.getByTestId("command-center-panel").getByText(input.path, { exact: true }).click();
|
||||
await expect(page.getByTestId("image-file-preview")).toBeVisible();
|
||||
}
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("needle");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel.getByRole("button", { name: input.result, exact: true })).toBeVisible();
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("needle");
|
||||
await expect(panel.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
const source = page.getByTestId("file-source-editor");
|
||||
await expect(source).toContainText("needle");
|
||||
await expect(source).not.toContainText("different file");
|
||||
await expect(source.locator(".cm-selectionBackground")).toBeVisible();
|
||||
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
|
||||
if (input.cursor) await expect(page.getByLabel(input.cursor, { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: screenshot });
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkspaceContentSearch } from "./workspace-content-search";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
@@ -91,6 +92,7 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||
const CONTENT_SEARCH_SNAP_POINTS = ["90%"];
|
||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||
|
||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
@@ -259,7 +261,7 @@ function useCommandCenterState(): CommandCenterState {
|
||||
error: fileSearchError,
|
||||
openFile,
|
||||
} = useWorkspaceFileSearch({
|
||||
enabled: open && (scope === "files" || Boolean(query.trim())),
|
||||
enabled: open && scope !== "content" && (scope === "files" || Boolean(query.trim())),
|
||||
query,
|
||||
});
|
||||
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
|
||||
@@ -584,6 +586,7 @@ export function CommandCenter() {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const contentKeyHandler = useRef<((key: string) => boolean) | null>(null);
|
||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||
const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
|
||||
@@ -707,7 +710,11 @@ export function CommandCenter() {
|
||||
const submit = useCallback(() => state.key("Enter"), [state]);
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!state.key(event.key)) return false;
|
||||
const handled =
|
||||
state.scope === "content"
|
||||
? (contentKeyHandler.current?.(event.key) ?? false)
|
||||
: state.key(event.key);
|
||||
if (!handled) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
@@ -730,7 +737,9 @@ export function CommandCenter() {
|
||||
<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}
|
||||
@@ -743,54 +752,31 @@ export function CommandCenter() {
|
||||
keyboardBlurBehavior="restore"
|
||||
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}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
{state.scope === "content" ? (
|
||||
<WorkspaceContentSearch
|
||||
compact={isCompact}
|
||||
bottomSheet
|
||||
close={state.close}
|
||||
clearScope={state.clearScope}
|
||||
keyHandler={contentKeyHandler}
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
<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">
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={[styles.bottomSheetHeader, styles.searchRow]}
|
||||
testID="command-center-header"
|
||||
>
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedTextInput
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
ref={bottomSheetInputRef}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
@@ -807,7 +793,61 @@ export function CommandCenter() {
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<FlatList ref={listRef} {...commonListProps} />
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</>
|
||||
)}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
<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, state.scope === "content" && styles.contentPanel]}
|
||||
>
|
||||
{state.scope === "content" ? (
|
||||
<WorkspaceContentSearch
|
||||
compact={isCompact}
|
||||
bottomSheet={false}
|
||||
close={state.close}
|
||||
clearScope={state.clearScope}
|
||||
keyHandler={contentKeyHandler}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
initialValue={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<FlatList ref={listRef} {...commonListProps} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
@@ -858,6 +898,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingTop: theme.spacing[12],
|
||||
},
|
||||
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
||||
contentPanel: { width: 1000, maxWidth: "96%", height: "75%" },
|
||||
panel: {
|
||||
width: 640,
|
||||
height: 560,
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type NativeSyntheticEvent,
|
||||
type TextInputKeyPressEventData,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EditingTextInput } from "@/components/ui/text-input";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { createWorkspaceFileTabTarget } from "@/workspace/file-open";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { WorkspaceFileSource } from "@/file-pane/source";
|
||||
import { WorkspaceContentSearchModel, type ContentSearchTransport } from "./internal/model";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
import type { WorkspaceContentMatch } from "@getpaseo/protocol/messages";
|
||||
import type { ContentSearchSnapshot } from "./internal/model";
|
||||
interface Props {
|
||||
compact: boolean;
|
||||
bottomSheet: boolean;
|
||||
close(): void;
|
||||
clearScope(): void;
|
||||
keyHandler: React.RefObject<((key: string) => boolean) | null>;
|
||||
}
|
||||
export function WorkspaceContentSearch(props: Props) {
|
||||
const { keyHandler, close, clearScope } = props;
|
||||
const { t } = useTranslation();
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const workspaceId = selection?.workspaceId ?? null;
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const session = useSessionStore((state) => (serverId ? state.sessions[serverId] : undefined));
|
||||
const unavailable =
|
||||
!session?.client ||
|
||||
!cwd ||
|
||||
!serverId ||
|
||||
!workspaceId ||
|
||||
!session.serverInfo?.features?.workspaceContentSearch;
|
||||
useEffect(() => {
|
||||
if (!unavailable) return;
|
||||
keyHandler.current = (key) => {
|
||||
if (key === "Escape") close();
|
||||
else if (key === "Backspace") clearScope();
|
||||
return ["Enter", "ArrowUp", "ArrowDown", "Escape", "Backspace"].includes(key);
|
||||
};
|
||||
return () => {
|
||||
keyHandler.current = null;
|
||||
};
|
||||
}, [unavailable, keyHandler, close, clearScope]);
|
||||
if (!session?.client || !cwd || !serverId || !workspaceId)
|
||||
return <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,
|
||||
compact,
|
||||
bottomSheet,
|
||||
close,
|
||||
clearScope,
|
||||
keyHandler,
|
||||
}: Props & {
|
||||
transport: ContentSearchTransport;
|
||||
cwd: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [previewPage, setPreviewPage] = useState(false);
|
||||
const closeRef = useRef(close);
|
||||
closeRef.current = close;
|
||||
const model = useMemo(
|
||||
() =>
|
||||
new WorkspaceContentSearchModel({
|
||||
cwd,
|
||||
transport,
|
||||
open(location) {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!workspaceKey) return;
|
||||
const layout = useWorkspaceLayoutStore.getState();
|
||||
const tabId = layout.openTab({
|
||||
workspaceKey,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
intent: "reveal",
|
||||
});
|
||||
if (tabId) layout.focusTab(workspaceKey, tabId);
|
||||
usePanelStore.getState().showMobileAgent();
|
||||
closeRef.current();
|
||||
},
|
||||
}),
|
||||
[cwd, transport, serverId, workspaceId],
|
||||
);
|
||||
useEffect(() => () => model.dispose(), [model]);
|
||||
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const listRef = useRef<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 onKey = useCallback(
|
||||
({ nativeEvent }: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
if (bottomSheet) key(nativeEvent.key);
|
||||
},
|
||||
[bottomSheet, key],
|
||||
);
|
||||
const changeQuery = useCallback(
|
||||
(value: string) => {
|
||||
setPreviewPage(false);
|
||||
model.setQuery(value);
|
||||
},
|
||||
[model],
|
||||
);
|
||||
const select = useCallback(
|
||||
(index: number) => {
|
||||
model.select(index);
|
||||
if (compact) setPreviewPage(true);
|
||||
},
|
||||
[model, compact],
|
||||
);
|
||||
const back = useCallback(() => setPreviewPage(false), []);
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: WorkspaceContentMatch; index: number }) => (
|
||||
<ResultRow item={item} index={index} selected={state.activeIndex === index} select={select} />
|
||||
),
|
||||
[select, state.activeIndex],
|
||||
);
|
||||
const empty = useMemo(() => {
|
||||
let text = "";
|
||||
if (state.status === "idle") text = t("shell.commandCenter.contentEmpty");
|
||||
if (state.status === "ready") text = t("shell.commandCenter.noMatches");
|
||||
return <Text style={styles.status}>{text}</Text>;
|
||||
}, [state.status, t]);
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<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={empty}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{!compact || previewPage ? (
|
||||
<ContentPreview state={state} model={model} compact={compact} back={back} />
|
||||
) : null}
|
||||
</View>
|
||||
<SearchStatus state={state} retry={model.retry} />
|
||||
<View style={styles.query}>
|
||||
<Button variant="ghost" size="sm" onPress={clearScope}>
|
||||
{t("shell.commandCenter.contentTitle")}
|
||||
</Button>
|
||||
<EditingTextInput
|
||||
initialValue={state.query}
|
||||
onChangeText={changeQuery}
|
||||
variant={bottomSheet ? "bottom-sheet" : "default"}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
accessibilityLabel={t("shell.commandCenter.contentPlaceholder")}
|
||||
placeholder={t("shell.commandCenter.contentPlaceholder")}
|
||||
onKeyPress={onKey}
|
||||
onSubmitEditing={model.openSelected}
|
||||
style={styles.input}
|
||||
/>
|
||||
</View>
|
||||
</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]);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${item.path}:${item.line}:${item.columnStart} ${item.snippet}`}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-pressed={isWeb ? selected : undefined}
|
||||
onPress={onPress}
|
||||
style={[styles.row, selected && styles.selected]}
|
||||
>
|
||||
<Text style={styles.path} numberOfLines={1}>
|
||||
{item.path}:{item.line}:{item.columnStart}
|
||||
</Text>
|
||||
<Text style={styles.snippet} numberOfLines={1}>
|
||||
{item.snippet.slice(0, item.snippetMatchStart)}
|
||||
<Text style={styles.match}>
|
||||
{item.snippet.slice(item.snippetMatchStart, item.snippetMatchEnd)}
|
||||
</Text>
|
||||
{item.snippet.slice(item.snippetMatchEnd)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
function resultKey(item: WorkspaceContentMatch) {
|
||||
return `${item.path}:${item.line}:${item.columnStart}`;
|
||||
}
|
||||
function resultLayout(_data: ArrayLike<WorkspaceContentMatch> | null | undefined, index: number) {
|
||||
return { length: 56, offset: index * 56, index };
|
||||
}
|
||||
function ContentPreview({
|
||||
state,
|
||||
model,
|
||||
compact,
|
||||
back,
|
||||
}: {
|
||||
state: ContentSearchSnapshot;
|
||||
model: WorkspaceContentSearchModel;
|
||||
compact: boolean;
|
||||
back(): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const match = state.matches[state.activeIndex];
|
||||
const preview = state.preview;
|
||||
const location = useMemo(
|
||||
() =>
|
||||
match
|
||||
? {
|
||||
path: match.path,
|
||||
lineStart: match.line,
|
||||
columnStart: match.columnStart,
|
||||
columnEnd: match.columnEnd,
|
||||
expectedText: match.text,
|
||||
}
|
||||
: null,
|
||||
[match],
|
||||
);
|
||||
return (
|
||||
<View style={styles.preview}>
|
||||
<View style={styles.previewHeader}>
|
||||
{compact ? (
|
||||
<Button variant="ghost" size="sm" onPress={back}>
|
||||
{t("shell.commandCenter.contentBack")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Text style={styles.previewPath} numberOfLines={1}>
|
||||
{match?.path ?? t("shell.commandCenter.contentPreview")}
|
||||
</Text>
|
||||
<Button variant="outline" size="sm" disabled={!match} onPress={model.openSelected}>
|
||||
{t("shell.commandCenter.contentOpen")}
|
||||
</Button>
|
||||
</View>
|
||||
{preview.status === "loading" ? (
|
||||
<Text style={styles.status}>{t("shell.commandCenter.contentLoadingPreview")}</Text>
|
||||
) : null}
|
||||
{preview.status === "error" ? (
|
||||
<View style={styles.status}>
|
||||
<Text style={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>
|
||||
);
|
||||
}
|
||||
function SearchStatus({ state, retry }: { state: ContentSearchSnapshot; retry(): void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.statusRow} accessibilityLiveRegion="polite">
|
||||
{state.status === "searching" ? (
|
||||
<>
|
||||
<ThemedSpinner size={14} />
|
||||
<Text style={styles.hint}>{t("shell.commandCenter.searchingFiles")}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{state.status === "error" ? (
|
||||
<>
|
||||
<Text style={[styles.hint, styles.error]}>{state.message}</Text>
|
||||
<Button variant="outline" size="sm" onPress={retry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{state.limited ? (
|
||||
<Text style={styles.hint}>{t("shell.commandCenter.contentLimited")}</Text>
|
||||
) : null}
|
||||
{state.status === "idle" || (state.status === "ready" && !state.limited) ? (
|
||||
<Text
|
||||
style={styles.hint}
|
||||
accessibilityHint={t("shell.commandCenter.contentScopeDetail")}
|
||||
aria-description={isWeb ? t("shell.commandCenter.contentScopeDetail") : undefined}
|
||||
>
|
||||
{t("shell.commandCenter.contentScope")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flex: 1, minHeight: 0 },
|
||||
body: { flex: 1, minHeight: 0, flexDirection: "row" },
|
||||
results: {
|
||||
width: "40%",
|
||||
minHeight: 0,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
},
|
||||
full: { width: "100%", borderRightWidth: 0 },
|
||||
row: {
|
||||
height: 56,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
selected: { backgroundColor: theme.colors.surface2 },
|
||||
path: { color: theme.colors.foreground, fontSize: theme.fontSize.sm },
|
||||
snippet: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
match: {
|
||||
color: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.terminal.selectionBackground,
|
||||
},
|
||||
preview: { flex: 1, minWidth: 0, minHeight: 0 },
|
||||
previewHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
previewPath: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
status: {
|
||||
padding: theme.spacing[4],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
statusRow: {
|
||||
minHeight: 40,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
hint: { flex: 1, fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
|
||||
error: { color: theme.colors.statusDanger },
|
||||
query: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
padding: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
...(isWeb ? { outlineWidth: 0 } : {}),
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,203 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { FileReadResult } from "@getpaseo/client";
|
||||
import type {
|
||||
WorkspaceContentMatch,
|
||||
WorkspaceContentSearchResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { WorkspaceContentSearchModel, type ContentSearchTransport } from "./model";
|
||||
|
||||
function deferred<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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import type {
|
||||
WorkspaceContentMatch,
|
||||
WorkspaceContentSearchResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import type { FileReadResult } from "@getpaseo/client";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
|
||||
export interface ContentSearchTransport {
|
||||
searchWorkspaceContent(
|
||||
input: { cwd: string; query: string },
|
||||
options: { signal: AbortSignal },
|
||||
): Promise<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;
|
||||
activeIndex: number;
|
||||
preview: Preview;
|
||||
}
|
||||
|
||||
export class WorkspaceContentSearchModel {
|
||||
private snapshot: ContentSearchSnapshot = {
|
||||
query: "",
|
||||
status: "idle",
|
||||
message: "",
|
||||
matches: [],
|
||||
limited: false,
|
||||
activeIndex: 0,
|
||||
preview: { status: "empty" },
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
private controller: AbortController | null = null;
|
||||
private timer: ReturnType<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,
|
||||
activeIndex: 0,
|
||||
preview: { status: "empty" },
|
||||
});
|
||||
if (!query || this.disposed) return;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
this.timer = setTimeout(
|
||||
() => void this.search(query, controller),
|
||||
this.options.debounceMs ?? 100,
|
||||
);
|
||||
};
|
||||
private async search(query: string, controller: AbortController) {
|
||||
try {
|
||||
const result = await this.options.transport.searchWorkspaceContent(
|
||||
{ cwd: this.options.cwd, query },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (this.disposed || controller.signal.aborted) return;
|
||||
if (result.status === "error") {
|
||||
this.update({ status: "error", message: result.message });
|
||||
return;
|
||||
}
|
||||
this.update({ status: "ready", matches: result.matches, limited: result.limited });
|
||||
this.select(0);
|
||||
} catch (error) {
|
||||
if (!this.disposed && !controller.signal.aborted)
|
||||
this.update({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "File search failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
select = (index: number) => {
|
||||
const activeIndex = Math.max(0, Math.min(index, this.snapshot.matches.length - 1));
|
||||
this.update({ activeIndex });
|
||||
void this.preview();
|
||||
};
|
||||
move = (direction: number) =>
|
||||
this.select(
|
||||
(this.snapshot.activeIndex + direction + this.snapshot.matches.length) %
|
||||
(this.snapshot.matches.length || 1),
|
||||
);
|
||||
private async preview() {
|
||||
const match = this.snapshot.matches[this.snapshot.activeIndex];
|
||||
if (!match) return;
|
||||
if (this.file?.path === match.path) {
|
||||
this.update({
|
||||
preview: {
|
||||
status: "ready",
|
||||
...this.file,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.update({ preview: { status: "loading" } });
|
||||
if (this.reading) return;
|
||||
this.reading = true;
|
||||
const path = match.path;
|
||||
const query = this.controller;
|
||||
try {
|
||||
const file = await this.options.transport.readFile(
|
||||
this.options.cwd,
|
||||
path,
|
||||
undefined,
|
||||
1_048_576,
|
||||
);
|
||||
if (this.disposed) return;
|
||||
if (query === this.controller) {
|
||||
const content = explorerFileFromReadResult(file).content;
|
||||
if (content === undefined) throw new Error("This file is no longer text");
|
||||
this.file = {
|
||||
path,
|
||||
content,
|
||||
size: file.size,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!this.disposed &&
|
||||
query === this.controller &&
|
||||
this.snapshot.matches[this.snapshot.activeIndex]?.path === path
|
||||
) {
|
||||
this.update({
|
||||
preview: {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "Preview unavailable",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
this.reading = false;
|
||||
}
|
||||
if (!this.disposed) void this.preview();
|
||||
}
|
||||
retry = () => {
|
||||
this.file = null;
|
||||
this.setQuery(this.snapshot.query);
|
||||
};
|
||||
retryPreview = () => {
|
||||
this.file = null;
|
||||
void this.preview();
|
||||
};
|
||||
openSelected = () => {
|
||||
const match = this.snapshot.matches[this.snapshot.activeIndex];
|
||||
if (!match || this.snapshot.status !== "ready") return;
|
||||
this.options.open({
|
||||
path: match.path,
|
||||
lineStart: match.line,
|
||||
columnStart: match.columnStart,
|
||||
columnEnd: match.columnEnd,
|
||||
expectedText: match.text,
|
||||
});
|
||||
};
|
||||
dispose = () => {
|
||||
this.disposed = true;
|
||||
this.controller?.abort();
|
||||
clearTimeout(this.timer);
|
||||
this.listeners.clear();
|
||||
this.file = null;
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { FileFind, FileFindModel } from "../find/index.web";
|
||||
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
|
||||
@@ -5,7 +6,7 @@ import { EditorView } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import { getCM, vim } from "@replit/codemirror-vim";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { FileEditorModel } from "./model";
|
||||
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
|
||||
|
||||
@@ -39,6 +40,7 @@ export function FileEditorView({
|
||||
onCursorChange,
|
||||
onVimModeChange,
|
||||
}: FileEditorViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [find] = useState(() => new FileFindModel());
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
@@ -102,15 +104,12 @@ export function FileEditorView({
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !location.lineStart) return;
|
||||
const lineStart = Math.min(location.lineStart, view.state.doc.lines);
|
||||
const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines);
|
||||
const from = view.state.doc.line(lineStart).from;
|
||||
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
|
||||
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
|
||||
selection: { anchor: from, head: to },
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
}, [location.lineEnd, location.lineStart, navigationRevision]);
|
||||
}, [location, navigationRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
@@ -143,8 +142,13 @@ export function FileEditorView({
|
||||
return () => cm.off("vim-mode-change", handleModeChange);
|
||||
}, [onVimModeChange, vimEnabled]);
|
||||
|
||||
const changed = resolveWorkspaceFileSelection(
|
||||
snapshot.content.replace(/\r\n?/g, "\n"),
|
||||
location,
|
||||
).changed;
|
||||
return (
|
||||
<div style={FRAME_STYLE}>
|
||||
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
@@ -158,8 +162,11 @@ export function FileEditorView({
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -58,14 +58,6 @@ interface FilePreviewBodyProps {
|
||||
|
||||
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
@@ -164,7 +156,7 @@ function FilePreviewBody({
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
if (preview.kind === "text" || (location.lineStart && preview.content !== undefined)) {
|
||||
if (renderKind === "html") {
|
||||
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
|
||||
return (
|
||||
@@ -239,8 +231,8 @@ export function FilePane({
|
||||
const supportsEditing = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||
const normalizedWorkspaceRoot = workspaceRoot;
|
||||
const normalizedFilePath = location.path || null;
|
||||
const readTarget = useMemo(
|
||||
() =>
|
||||
normalizedFilePath
|
||||
|
||||
@@ -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,9 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FileFind, FileFindModel } from "../find/index.web";
|
||||
import { Compartment, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { EditorView, drawSelection } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { editorTheme } from "../editor/extensions.web";
|
||||
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
|
||||
@@ -60,6 +61,7 @@ function ReadonlyCodeMirror({
|
||||
}: Omit<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);
|
||||
@@ -79,6 +81,9 @@ function ReadonlyCodeMirror({
|
||||
tabindex: "0",
|
||||
"aria-label": `Source for ${values.filename}`,
|
||||
}),
|
||||
// The preview is never focused, so the browser draws no selection of its own; the
|
||||
// exact occurrence has to be painted by CodeMirror.
|
||||
drawSelection(),
|
||||
EditorView.editable.of(false),
|
||||
languageCompartment.of(
|
||||
languageFor({ filename: values.filename, presentation: values.presentation }),
|
||||
@@ -112,13 +117,17 @@ function ReadonlyCodeMirror({
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !location.lineStart) return;
|
||||
const line = Math.min(location.lineStart, view.state.doc.lines);
|
||||
const from = view.state.doc.line(line).from;
|
||||
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
|
||||
}, [location.lineStart, navigationRevision]);
|
||||
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: to },
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
}, [content, location, navigationRevision]);
|
||||
|
||||
const changed = resolveWorkspaceFileSelection(content.replace(/\r\n?/g, "\n"), location).changed;
|
||||
return (
|
||||
<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>
|
||||
@@ -134,8 +143,11 @@ function languageFor(input: {
|
||||
: [];
|
||||
}
|
||||
|
||||
// The Find widget floats inside this frame, so it stays relative; the change notice is the
|
||||
// only row that shares the column with the editor host.
|
||||
const FRAME_STYLE = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -73,6 +73,21 @@ export const ar: TranslationResources = {
|
||||
close: "إغلاق القائمة",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "البحث في محتوى الملفات",
|
||||
contentPlaceholder: "البحث في الملفات المحفوظة...",
|
||||
contentUnavailable: "مساحة العمل غير متاحة",
|
||||
contentUpdateHost: "حدّث هذا المضيف للبحث في محتوى الملفات",
|
||||
contentEmpty: "البحث في الملفات المحفوظة",
|
||||
contentBack: "العودة إلى النتائج",
|
||||
contentPreview: "معاينة",
|
||||
contentOpen: "فتح",
|
||||
contentLoadingPreview: "جارٍ تحميل المعاينة...",
|
||||
contentLimited: "النتائج محدودة — حدّد البحث أكثر",
|
||||
contentScopeDetail:
|
||||
"يبحث في ملفات UTF-8 المحفوظة، بما فيها المخفية وغير المتتبعة. تُطبق قواعد التجاهل. لا يشمل الروابط الرمزية والتعديلات غير المحفوظة.",
|
||||
contentScope: "ملفات محفوظة حتى 1 MiB · تجاهل حالة الأحرف",
|
||||
contentChanged: "تغيّر الملف — لم تعد المطابقة في هذا الموضع",
|
||||
|
||||
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
|
||||
filePlaceholder: "البحث في الملفات...",
|
||||
searchingFiles: "جارٍ البحث في الملفات...",
|
||||
|
||||
@@ -70,6 +70,21 @@ export const en = {
|
||||
close: "Close menu",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Search file contents",
|
||||
contentPlaceholder: "Search saved file contents...",
|
||||
contentUnavailable: "Workspace unavailable",
|
||||
contentUpdateHost: "Update this host to search file contents",
|
||||
contentEmpty: "Search saved file contents",
|
||||
contentBack: "Back to results",
|
||||
contentPreview: "Preview",
|
||||
contentOpen: "Open",
|
||||
contentLoadingPreview: "Loading preview...",
|
||||
contentLimited: "Results limited — refine your search",
|
||||
contentScopeDetail:
|
||||
"Searches saved UTF-8 files, including hidden and untracked files. Ignore rules apply. Symbolic links and unsaved edits are excluded.",
|
||||
contentScope: "Saved files up to 1 MiB · Ignore case",
|
||||
contentChanged: "File changed — saved match is no longer at this location",
|
||||
|
||||
placeholder: "Search commands, files, workspaces, and agents...",
|
||||
filePlaceholder: "Search files...",
|
||||
searchingFiles: "Searching files...",
|
||||
|
||||
@@ -73,6 +73,21 @@ export const es: TranslationResources = {
|
||||
close: "Cerrar menú",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Buscar contenido de archivos",
|
||||
contentPlaceholder: "Buscar contenido guardado...",
|
||||
contentUnavailable: "Espacio de trabajo no disponible",
|
||||
contentUpdateHost: "Actualiza este host para buscar contenido",
|
||||
contentEmpty: "Buscar contenido guardado",
|
||||
contentBack: "Volver a resultados",
|
||||
contentPreview: "Vista previa",
|
||||
contentOpen: "Abrir",
|
||||
contentLoadingPreview: "Cargando vista previa...",
|
||||
contentLimited: "Resultados limitados — precisa la búsqueda",
|
||||
contentScopeDetail:
|
||||
"Busca archivos UTF-8 guardados, incluidos ocultos y sin seguimiento. Respeta las reglas de exclusión. Excluye enlaces simbólicos y cambios sin guardar.",
|
||||
contentScope: "Archivos guardados hasta 1 MiB · Sin distinguir mayúsculas",
|
||||
contentChanged: "El archivo cambió — la coincidencia ya no está aquí",
|
||||
|
||||
placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...",
|
||||
filePlaceholder: "Buscar archivos...",
|
||||
searchingFiles: "Buscando archivos...",
|
||||
|
||||
@@ -74,6 +74,21 @@ export const fr: TranslationResources = {
|
||||
close: "Fermer le menu",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Rechercher dans les fichiers",
|
||||
contentPlaceholder: "Rechercher dans les fichiers enregistrés...",
|
||||
contentUnavailable: "Espace de travail indisponible",
|
||||
contentUpdateHost: "Mettez cet hôte à jour pour rechercher dans les fichiers",
|
||||
contentEmpty: "Rechercher dans les fichiers enregistrés",
|
||||
contentBack: "Retour aux résultats",
|
||||
contentPreview: "Aperçu",
|
||||
contentOpen: "Ouvrir",
|
||||
contentLoadingPreview: "Chargement de l’aperçu...",
|
||||
contentLimited: "Résultats limités — précisez la recherche",
|
||||
contentScopeDetail:
|
||||
"Recherche dans les fichiers UTF-8 enregistrés, y compris cachés et non suivis. Respecte les règles d’exclusion. Exclut les liens symboliques et les modifications non enregistrées.",
|
||||
contentScope: "Fichiers enregistrés jusqu’à 1 Mio · Casse ignorée",
|
||||
contentChanged: "Fichier modifié — la correspondance n’est plus à cet emplacement",
|
||||
|
||||
placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...",
|
||||
filePlaceholder: "Rechercher des fichiers...",
|
||||
searchingFiles: "Recherche de fichiers...",
|
||||
|
||||
@@ -73,6 +73,21 @@ export const ja: TranslationResources = {
|
||||
close: "メニューを閉じる",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "ファイル内容を検索",
|
||||
contentPlaceholder: "保存済みファイルの内容を検索...",
|
||||
contentUnavailable: "ワークスペースを利用できません",
|
||||
contentUpdateHost: "ファイル内容を検索するにはホストを更新してください",
|
||||
contentEmpty: "保存済みファイルの内容を検索",
|
||||
contentBack: "検索結果に戻る",
|
||||
contentPreview: "プレビュー",
|
||||
contentOpen: "開く",
|
||||
contentLoadingPreview: "プレビューを読み込み中...",
|
||||
contentLimited: "結果が上限に達しました — 検索を絞り込んでください",
|
||||
contentScopeDetail:
|
||||
"非表示・未追跡のファイルを含む保存済み UTF-8 ファイルを検索します。除外ルールに従い、シンボリックリンクと未保存の編集は検索しません。",
|
||||
contentScope: "1 MiB 以下の保存済みファイル · 大文字小文字を区別しない",
|
||||
contentChanged: "ファイルが変更され、この位置に一致する内容はありません",
|
||||
|
||||
placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...",
|
||||
filePlaceholder: "ファイルを検索...",
|
||||
searchingFiles: "ファイルを検索中...",
|
||||
|
||||
@@ -73,6 +73,21 @@ export const ko: TranslationResources = {
|
||||
close: "메뉴 닫기",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "파일 내용 검색",
|
||||
contentPlaceholder: "저장된 파일 내용 검색...",
|
||||
contentUnavailable: "작업 공간을 사용할 수 없음",
|
||||
contentUpdateHost: "파일 내용을 검색하려면 호스트를 업데이트하세요",
|
||||
contentEmpty: "저장된 파일 내용 검색",
|
||||
contentBack: "결과로 돌아가기",
|
||||
contentPreview: "미리보기",
|
||||
contentOpen: "열기",
|
||||
contentLoadingPreview: "미리보기 로딩 중...",
|
||||
contentLimited: "결과 제한 — 검색 범위를 좁히세요",
|
||||
contentScopeDetail:
|
||||
"숨김 파일과 추적되지 않은 파일을 포함한 저장된 UTF-8 파일을 검색합니다. 무시 규칙을 따르며 심볼릭 링크와 저장되지 않은 편집은 제외합니다.",
|
||||
contentScope: "1 MiB 이하 저장된 파일 · 대소문자 구분 안 함",
|
||||
contentChanged: "파일이 변경되어 이 위치에 일치하는 내용이 없습니다",
|
||||
|
||||
placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...",
|
||||
filePlaceholder: "파일 검색...",
|
||||
searchingFiles: "파일 검색 중...",
|
||||
|
||||
@@ -73,6 +73,21 @@ export const ptBR: TranslationResources = {
|
||||
close: "Fechar menu",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Buscar conteúdo dos arquivos",
|
||||
contentPlaceholder: "Buscar conteúdo salvo...",
|
||||
contentUnavailable: "Espaço de trabalho indisponível",
|
||||
contentUpdateHost: "Atualize este host para buscar conteúdo",
|
||||
contentEmpty: "Buscar conteúdo salvo",
|
||||
contentBack: "Voltar aos resultados",
|
||||
contentPreview: "Prévia",
|
||||
contentOpen: "Abrir",
|
||||
contentLoadingPreview: "Carregando prévia...",
|
||||
contentLimited: "Resultados limitados — refine a busca",
|
||||
contentScopeDetail:
|
||||
"Pesquisa arquivos UTF-8 salvos, incluindo ocultos e não rastreados. Respeita as regras de exclusão. Exclui links simbólicos e edições não salvas.",
|
||||
contentScope: "Arquivos salvos até 1 MiB · Ignorar maiúsculas",
|
||||
contentChanged: "Arquivo alterado — a correspondência não está mais neste local",
|
||||
|
||||
placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...",
|
||||
filePlaceholder: "Pesquisar arquivos...",
|
||||
searchingFiles: "Pesquisando arquivos...",
|
||||
|
||||
@@ -73,6 +73,21 @@ export const ru: TranslationResources = {
|
||||
close: "Закрыть меню",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Поиск по содержимому файлов",
|
||||
contentPlaceholder: "Поиск в сохранённых файлах...",
|
||||
contentUnavailable: "Рабочее пространство недоступно",
|
||||
contentUpdateHost: "Обновите этот хост для поиска по содержимому",
|
||||
contentEmpty: "Поиск в сохранённых файлах",
|
||||
contentBack: "Назад к результатам",
|
||||
contentPreview: "Предпросмотр",
|
||||
contentOpen: "Открыть",
|
||||
contentLoadingPreview: "Загрузка предпросмотра...",
|
||||
contentLimited: "Результаты ограничены — уточните поиск",
|
||||
contentScopeDetail:
|
||||
"Поиск в сохранённых файлах UTF-8, включая скрытые и неотслеживаемые. Учитываются правила исключения. Символические ссылки и несохранённые изменения исключены.",
|
||||
contentScope: "Сохранённые файлы до 1 МиБ · Без учёта регистра",
|
||||
contentChanged: "Файл изменён — совпадения больше нет в этом месте",
|
||||
|
||||
placeholder: "Поиск команд, файлов, рабочих пространств и агентов...",
|
||||
filePlaceholder: "Поиск файлов...",
|
||||
searchingFiles: "Поиск файлов...",
|
||||
|
||||
@@ -73,6 +73,21 @@ export const zhCN: TranslationResources = {
|
||||
close: "关闭菜单",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "搜索文件内容",
|
||||
contentPlaceholder: "搜索已保存的文件内容...",
|
||||
contentUnavailable: "工作区不可用",
|
||||
contentUpdateHost: "请更新此主机以搜索文件内容",
|
||||
contentEmpty: "搜索已保存的文件内容",
|
||||
contentBack: "返回结果",
|
||||
contentPreview: "预览",
|
||||
contentOpen: "打开",
|
||||
contentLoadingPreview: "正在加载预览...",
|
||||
contentLimited: "结果已达上限 — 请缩小搜索范围",
|
||||
contentScopeDetail:
|
||||
"搜索已保存的 UTF-8 文件,包括隐藏和未跟踪的文件。遵循忽略规则,不搜索符号链接和未保存的编辑。",
|
||||
contentScope: "不超过 1 MiB 的已保存文件 · 忽略大小写",
|
||||
contentChanged: "文件已更改 — 此位置不再匹配",
|
||||
|
||||
placeholder: "搜索命令、文件、工作区和 Agent...",
|
||||
filePlaceholder: "搜索文件...",
|
||||
searchingFiles: "正在搜索文件...",
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -207,6 +207,9 @@ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
|
||||
path: z.string(),
|
||||
lineStart: z.number().int().positive().optional(),
|
||||
lineEnd: z.number().int().positive().optional(),
|
||||
columnStart: z.number().int().positive().optional(),
|
||||
columnEnd: z.number().int().positive().optional(),
|
||||
expectedText: z.string().optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("working_diff"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveWorkspaceFileSelection,
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
resolveWorkspaceFilePaths,
|
||||
@@ -34,7 +35,7 @@ describe("normalizeWorkspaceFileLocation", () => {
|
||||
});
|
||||
|
||||
it("rejects empty paths", () => {
|
||||
expect(normalizeWorkspaceFileLocation({ path: " " })).toBeNull();
|
||||
expect(normalizeWorkspaceFileLocation({ path: "" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,3 +184,45 @@ describe("resolveWorkspaceFilePaths", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("selects exact UTF-16 columns and refuses a stale saved match", () => {
|
||||
const location = {
|
||||
path: "a.ts",
|
||||
lineStart: 2,
|
||||
columnStart: 5,
|
||||
columnEnd: 11,
|
||||
expectedText: "NEEDLE",
|
||||
};
|
||||
expect(resolveWorkspaceFileSelection("first\né🙂 NEEDLE", location)).toEqual({
|
||||
from: 10,
|
||||
to: 16,
|
||||
changed: false,
|
||||
});
|
||||
expect(resolveWorkspaceFileSelection("first\né🙂 change", location)).toEqual({
|
||||
from: 10,
|
||||
to: 10,
|
||||
changed: true,
|
||||
});
|
||||
expect(workspaceFileLocationsEqual(location, { ...location, columnStart: 6 })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not select an identical occurrence on another line after the saved line disappears", () => {
|
||||
expect(
|
||||
resolveWorkspaceFileSelection("needle", {
|
||||
path: "a.ts",
|
||||
lineStart: 3,
|
||||
columnStart: 1,
|
||||
columnEnd: 7,
|
||||
expectedText: "needle",
|
||||
}),
|
||||
).toEqual({ from: 0, to: 0, changed: true });
|
||||
});
|
||||
|
||||
it("preserves literal filename and workspace whitespace through location and host paths", () => {
|
||||
const location = { path: " leading.txt ", lineStart: 1 };
|
||||
expect(normalizeWorkspaceFileLocation(location)).toEqual(location);
|
||||
expect(resolveWorkspaceFilePaths({ path: location.path, workspaceRoot: "/workspace " })).toEqual({
|
||||
absolutePath: "/workspace / leading.txt ",
|
||||
relativePath: " leading.txt ",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,15 @@ export type OpenFileDisposition = "main" | "preferred" | "side";
|
||||
|
||||
export interface WorkspaceFileLocation {
|
||||
path: string;
|
||||
/** One-based source line; CRLF, CR and LF each separate lines. */
|
||||
lineStart?: number;
|
||||
lineEnd?: number;
|
||||
/** One-based UTF-16 column on lineStart. */
|
||||
columnStart?: number;
|
||||
/** Exclusive UTF-16 column on the same line. */
|
||||
columnEnd?: number;
|
||||
/** Original saved occurrence; a mismatch reveals the location without selecting text. */
|
||||
expectedText?: string;
|
||||
}
|
||||
|
||||
export type WorkspaceFileTabTarget = { kind: "file" } & WorkspaceFileLocation;
|
||||
@@ -22,16 +29,21 @@ export function normalizeWorkspaceFileLocation(
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = location.path.trim().replace(/\\/g, "/");
|
||||
const path = location.path.replace(/\\/g, "/");
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineStart = normalizeLineNumber(location.lineStart);
|
||||
const lineEnd = normalizeLineNumber(location.lineEnd);
|
||||
const columnStart = normalizeLineNumber(location.columnStart);
|
||||
const columnEnd = normalizeLineNumber(location.columnEnd);
|
||||
return {
|
||||
path,
|
||||
...(lineStart ? { lineStart } : {}),
|
||||
...(lineStart && columnStart ? { columnStart } : {}),
|
||||
...(lineStart && columnEnd && columnEnd >= (columnStart ?? 1) ? { columnEnd } : {}),
|
||||
...(location.expectedText !== undefined ? { expectedText: location.expectedText } : {}),
|
||||
...(lineStart && lineEnd && lineEnd >= lineStart ? { lineEnd } : {}),
|
||||
};
|
||||
}
|
||||
@@ -41,7 +53,12 @@ export function workspaceFileLocationsEqual(
|
||||
right: WorkspaceFileLocation,
|
||||
): boolean {
|
||||
return (
|
||||
left.path === right.path && left.lineStart === right.lineStart && left.lineEnd === right.lineEnd
|
||||
left.path === right.path &&
|
||||
left.lineStart === right.lineStart &&
|
||||
left.lineEnd === right.lineEnd &&
|
||||
left.columnStart === right.columnStart &&
|
||||
left.columnEnd === right.columnEnd &&
|
||||
left.expectedText === right.expectedText
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,8 +162,8 @@ export function resolveWorkspaceFilePaths(input: {
|
||||
path: string;
|
||||
workspaceRoot: string;
|
||||
}): ResolvedWorkspaceFilePaths | null {
|
||||
const filePath = input.path.trim().replace(/\\/g, "/");
|
||||
const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
|
||||
const filePath = input.path.replace(/\\/g, "/");
|
||||
const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.replace(/\\/g, "/"));
|
||||
if (!filePath || !workspaceRoot) {
|
||||
return null;
|
||||
}
|
||||
@@ -176,3 +193,26 @@ export function resolveWorkspaceFilePaths(input: {
|
||||
}
|
||||
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
|
||||
}
|
||||
|
||||
/** Resolves a saved location without manufacturing a selection when its text has moved. */
|
||||
export function resolveWorkspaceFileSelection(
|
||||
content: string,
|
||||
location: WorkspaceFileLocation,
|
||||
): { from: number; to: number; changed: boolean } {
|
||||
const lines = content.split("\n");
|
||||
const index = Math.max(0, Math.min((location.lineStart ?? 1) - 1, lines.length - 1));
|
||||
const line = lines[index];
|
||||
const offset = lines.slice(0, index).reduce((sum, value) => sum + value.length + 1, 0);
|
||||
const from = offset + Math.min((location.columnStart ?? 1) - 1, line.length);
|
||||
const endIndex = Math.max(index, Math.min((location.lineEnd ?? index + 1) - 1, lines.length - 1));
|
||||
let to = from;
|
||||
if (location.columnEnd !== undefined) to = offset + Math.min(location.columnEnd - 1, line.length);
|
||||
else if (endIndex > index)
|
||||
to =
|
||||
lines.slice(0, endIndex).reduce((sum, value) => sum + value.length + 1, 0) +
|
||||
lines[endIndex].length;
|
||||
const changed =
|
||||
location.expectedText !== undefined &&
|
||||
(index !== (location.lineStart ?? 1) - 1 || content.slice(from, to) !== location.expectedText);
|
||||
return { from, to: changed ? from : to, changed };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2688,6 +2688,50 @@ export const FileVersionSchema = z.discriminatedUnion("status", [
|
||||
}),
|
||||
]);
|
||||
|
||||
export const WorkspaceContentMatchSchema = z.object({
|
||||
path: z.string(),
|
||||
line: z.number().int().positive(),
|
||||
columnStart: z.number().int().positive(),
|
||||
columnEnd: z.number().int().positive(),
|
||||
text: z.string(),
|
||||
snippet: z.string(),
|
||||
snippetMatchStart: z.number().int().nonnegative(),
|
||||
snippetMatchEnd: z.number().int().nonnegative(),
|
||||
});
|
||||
export const WorkspaceContentSearchResultSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("ok"),
|
||||
matches: z.array(WorkspaceContentMatchSchema),
|
||||
limited: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("error"),
|
||||
code: z.enum(["missing_rg", "timeout", "invalid_query", "unavailable", "busy", "cancelled"]),
|
||||
message: z.string(),
|
||||
}),
|
||||
]);
|
||||
export const WorkspaceContentSearchRequestSchema = z.object({
|
||||
type: z.literal("fs.content.search.request"),
|
||||
requestId: z.string(),
|
||||
cwd: z.string(),
|
||||
query: z.string(),
|
||||
});
|
||||
export const WorkspaceContentCancelRequestSchema = z.object({
|
||||
type: z.literal("fs.content.cancel.request"),
|
||||
requestId: z.string(),
|
||||
searchRequestId: z.string(),
|
||||
});
|
||||
export const WorkspaceContentSearchResponseSchema = z.object({
|
||||
type: z.literal("fs.content.search.response"),
|
||||
payload: z.object({ requestId: z.string(), result: WorkspaceContentSearchResultSchema }),
|
||||
});
|
||||
export const WorkspaceContentCancelResponseSchema = z.object({
|
||||
type: z.literal("fs.content.cancel.response"),
|
||||
payload: z.object({ requestId: z.string(), searchRequestId: z.string() }),
|
||||
});
|
||||
export type WorkspaceContentMatch = z.infer<typeof WorkspaceContentMatchSchema>;
|
||||
export type WorkspaceContentSearchResult = z.infer<typeof WorkspaceContentSearchResultSchema>;
|
||||
|
||||
export const FileSubscribeRequestSchema = z.object({
|
||||
type: z.literal("fs.file.subscribe.request"),
|
||||
cwd: z.string(),
|
||||
@@ -3211,6 +3255,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
WorkspaceClearAttentionRequestSchema,
|
||||
WorkspaceMarkUnreadRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
WorkspaceContentSearchRequestSchema,
|
||||
WorkspaceContentCancelRequestSchema,
|
||||
FileSubscribeRequestSchema,
|
||||
FileUnsubscribeRequestSchema,
|
||||
FileWriteRequestSchema,
|
||||
@@ -3516,6 +3562,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
workspaceRecovery: z.boolean().optional(),
|
||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||
workspaceFileEditing: z.boolean().optional(),
|
||||
workspaceContentSearch: z.boolean().optional(),
|
||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||
providerUsageList: z.boolean().optional(),
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
@@ -6617,6 +6664,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoWorktreeArchiveResponseSchema,
|
||||
CreatePaseoWorktreeResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
WorkspaceContentSearchResponseSchema,
|
||||
WorkspaceContentCancelResponseSchema,
|
||||
FileSubscribeResponseSchema,
|
||||
FileUnsubscribeResponseSchema,
|
||||
FileWriteResponseSchema,
|
||||
|
||||
@@ -81,6 +81,8 @@ const INBOUND_PERMISSION = {
|
||||
fetch_workspaces_request: ["workspace.read", "hub.execute"],
|
||||
"file.upload.request": "workspace.write",
|
||||
file_download_token_request: "workspace.read",
|
||||
"fs.content.search.request": "workspace.read",
|
||||
"fs.content.cancel.request": "workspace.read",
|
||||
file_explorer_request: "workspace.read",
|
||||
"forge.search.request": "workspace.read",
|
||||
"fs.entry.create.request": "workspace.write",
|
||||
@@ -292,6 +294,8 @@ const OUTBOUND_PERMISSION = {
|
||||
fetch_workspaces_response: ["workspace.read", "hub.execute"],
|
||||
"file.upload.response": "workspace.write",
|
||||
file_download_token_response: "workspace.read",
|
||||
"fs.content.search.response": "workspace.read",
|
||||
"fs.content.cancel.response": "workspace.read",
|
||||
file_explorer_response: "workspace.read",
|
||||
"forge.search.response": "workspace.read",
|
||||
"fs.entry.create.response": "workspace.write",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { expect, test } from "vitest";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
|
||||
test("real client searches saved contents, cancels host work and can search again", async () => {
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), "paseo-content-rpc-"));
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
||||
try {
|
||||
await writeFile(path.join(cwd, "a.ts"), "const original = 'NEEDLE';\n");
|
||||
await client.connect();
|
||||
await client.fetchAgents();
|
||||
const result = await client.searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(
|
||||
result.status === "ok" && result.matches.map((match) => [match.path, match.text]),
|
||||
).toEqual([["a.ts", "NEEDLE"]]);
|
||||
const controller = new AbortController();
|
||||
const searching = client.searchWorkspaceContent(
|
||||
{ cwd, query: "absent" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
expect(await searching).toMatchObject({ status: "error", code: "cancelled" });
|
||||
expect(await client.searchWorkspaceContent({ cwd, query: "missing" })).toEqual({
|
||||
status: "ok",
|
||||
matches: [],
|
||||
limited: false,
|
||||
});
|
||||
const preview = await client.readFile(cwd, "a.ts", undefined, 1_048_576);
|
||||
expect(new TextDecoder().decode(preview.bytes)).toBe("const original = 'NEEDLE';\n");
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2624,6 +2624,11 @@ export class Session {
|
||||
source?: object,
|
||||
): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "fs.content.search.request":
|
||||
return this.workspaceFilesSession.handleContentSearch(msg);
|
||||
case "fs.content.cancel.request":
|
||||
this.workspaceFilesSession.handleContentCancel(msg);
|
||||
return;
|
||||
case "file_explorer_request":
|
||||
return this.workspaceFilesSession.handleFileExplorerRequest(msg, source);
|
||||
case "fs.file.subscribe.request":
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { searchWorkspaceContent } from "../../workspace-content-search/index.js";
|
||||
import type pino from "pino";
|
||||
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||
import {
|
||||
@@ -63,6 +64,7 @@ export interface WorkspaceFilesSessionOptions {
|
||||
* the whole concern.
|
||||
*/
|
||||
export class WorkspaceFilesSession {
|
||||
private readonly searches = new Map<string, AbortController>();
|
||||
private readonly host: WorkspaceFilesSessionHost;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly logger: pino.Logger;
|
||||
@@ -78,6 +80,50 @@ export class WorkspaceFilesSession {
|
||||
this.fileObserver = options.fileObserver ?? workspaceFileObserver;
|
||||
}
|
||||
|
||||
async handleContentSearch(
|
||||
request: Extract<SessionInboundMessage, { type: "fs.content.search.request" }>,
|
||||
): Promise<void> {
|
||||
if (this.searches.size >= 2 || this.searches.has(request.requestId)) {
|
||||
this.host.emit({
|
||||
type: "fs.content.search.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
result: {
|
||||
status: "error",
|
||||
code: "busy",
|
||||
message: "Another search is still stopping — retry",
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
this.searches.set(request.requestId, controller);
|
||||
try {
|
||||
const result = await searchWorkspaceContent({
|
||||
cwd: request.cwd,
|
||||
query: request.query,
|
||||
signal: controller.signal,
|
||||
});
|
||||
this.host.emit({
|
||||
type: "fs.content.search.response",
|
||||
payload: { requestId: request.requestId, result },
|
||||
});
|
||||
} finally {
|
||||
this.searches.delete(request.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
handleContentCancel(
|
||||
request: Extract<SessionInboundMessage, { type: "fs.content.cancel.request" }>,
|
||||
): void {
|
||||
this.searches.get(request.searchRequestId)?.abort();
|
||||
this.host.emit({
|
||||
type: "fs.content.cancel.response",
|
||||
payload: { requestId: request.requestId, searchRequestId: request.searchRequestId },
|
||||
});
|
||||
}
|
||||
|
||||
async handleFileSubscribeRequest(request: FileSubscribeRequest): Promise<void> {
|
||||
this.fileSubscriptions.get(request.subscriptionId)?.();
|
||||
try {
|
||||
@@ -214,13 +260,14 @@ export class WorkspaceFilesSession {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const controller of this.searches.values()) controller.abort();
|
||||
for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe();
|
||||
this.fileSubscriptions.clear();
|
||||
}
|
||||
|
||||
async handleFileExplorerRequest(request: FileExplorerRequest, source?: object): Promise<void> {
|
||||
const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request;
|
||||
const cwd = workspaceCwd.trim();
|
||||
const cwd = workspaceCwd;
|
||||
if (!cwd) {
|
||||
this.host.emit(
|
||||
{
|
||||
@@ -270,41 +317,45 @@ export class WorkspaceFilesSession {
|
||||
}
|
||||
}
|
||||
if (request.acceptBinary && this.host.hasBinaryChannel()) {
|
||||
await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
|
||||
await this.host.emitBinary(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId,
|
||||
metadata: {
|
||||
mime: file.mimeType,
|
||||
size: file.size,
|
||||
encoding: file.encoding,
|
||||
modifiedAt: file.modifiedAt,
|
||||
revision: file.revision,
|
||||
},
|
||||
}),
|
||||
source,
|
||||
);
|
||||
for await (const chunk of file.chunks) {
|
||||
await streamExplorerFile(
|
||||
{ root: cwd, relativePath: requestedPath, maxBytes: request.maxBytes },
|
||||
async (file) => {
|
||||
await this.host.emitBinary(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId,
|
||||
payload: chunk,
|
||||
metadata: {
|
||||
mime: file.mimeType,
|
||||
size: file.size,
|
||||
encoding: file.encoding,
|
||||
modifiedAt: file.modifiedAt,
|
||||
revision: file.revision,
|
||||
},
|
||||
}),
|
||||
source,
|
||||
);
|
||||
}
|
||||
await this.host.emitBinary(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileEnd,
|
||||
requestId,
|
||||
}),
|
||||
source,
|
||||
);
|
||||
});
|
||||
for await (const chunk of file.chunks) {
|
||||
await this.host.emitBinary(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId,
|
||||
payload: chunk,
|
||||
}),
|
||||
source,
|
||||
);
|
||||
}
|
||||
await this.host.emitBinary(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileEnd,
|
||||
requestId,
|
||||
}),
|
||||
source,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const file = await readExplorerFile({
|
||||
maxBytes: request.maxBytes,
|
||||
root: cwd,
|
||||
relativePath: requestedPath,
|
||||
});
|
||||
@@ -391,7 +442,7 @@ export class WorkspaceFilesSession {
|
||||
|
||||
async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise<void> {
|
||||
const { cwd: workspaceCwd, path: requestedPath, requestId } = request;
|
||||
const cwd = workspaceCwd.trim();
|
||||
const cwd = workspaceCwd;
|
||||
if (!cwd) {
|
||||
this.host.emit({
|
||||
type: "file_download_token_response",
|
||||
|
||||
@@ -1712,6 +1712,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceRecovery: true,
|
||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||
workspaceFileEditing: true,
|
||||
workspaceContentSearch: true,
|
||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||
providerUsageList: true,
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { searchWorkspaceContent } from "./index.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
async function workspace(files: Record<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,
|
||||
matches: [
|
||||
{
|
||||
path: ".hidden",
|
||||
line: 1,
|
||||
columnStart: 1,
|
||||
columnEnd: 7,
|
||||
text: "Needle",
|
||||
snippet: "Needle",
|
||||
snippetMatchStart: 0,
|
||||
snippetMatchEnd: 6,
|
||||
},
|
||||
{
|
||||
path: "a.ts",
|
||||
line: 1,
|
||||
columnStart: 5,
|
||||
columnEnd: 11,
|
||||
text: "NEEDLE",
|
||||
snippet: "é🙂 NEEDLE needle",
|
||||
snippetMatchStart: 4,
|
||||
snippetMatchEnd: 10,
|
||||
},
|
||||
{
|
||||
path: "a.ts",
|
||||
line: 1,
|
||||
columnStart: 12,
|
||||
columnEnd: 18,
|
||||
text: "needle",
|
||||
snippet: "é🙂 NEEDLE needle",
|
||||
snippetMatchStart: 11,
|
||||
snippetMatchEnd: 17,
|
||||
},
|
||||
],
|
||||
});
|
||||
const literal = await searchWorkspaceContent({ cwd, query: "a.b" });
|
||||
expect(literal.status === "ok" && literal.matches.map((m) => m.text)).toEqual(["a.b"]);
|
||||
});
|
||||
|
||||
test("reports missing rg, empty search, cancellation and file/result limits", async () => {
|
||||
const cwd = await workspace({
|
||||
"many.txt": "needle\n".repeat(250),
|
||||
oversized: "needle" + " ".repeat(1_048_576),
|
||||
});
|
||||
const result = await searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(result.status === "ok" && [result.matches.length, result.limited]).toEqual([200, true]);
|
||||
expect(await searchWorkspaceContent({ cwd, query: "" })).toEqual({
|
||||
status: "ok",
|
||||
matches: [],
|
||||
limited: false,
|
||||
});
|
||||
expect(await searchWorkspaceContent({ cwd, query: "x" }, { env: { PATH: "" } })).toMatchObject({
|
||||
status: "error",
|
||||
code: "missing_rg",
|
||||
});
|
||||
expect(
|
||||
await searchWorkspaceContent({ cwd, query: "x", signal: AbortSignal.abort() }),
|
||||
).toMatchObject({ status: "error", code: "cancelled" });
|
||||
});
|
||||
|
||||
test("preserves Unicode case-fold matches and BOM offsets, rejects binary files and bounds long records", async () => {
|
||||
const cwd = await workspace({
|
||||
unicode: "\uFEFFé🙂 K k K ſ S s σ Σ ς\r\n",
|
||||
binary: "needle\0binary",
|
||||
long: "x".repeat(1_040_000) + "needle",
|
||||
});
|
||||
const kelvin = await searchWorkspaceContent({ cwd, query: "k" });
|
||||
expect(kelvin.status === "ok" && kelvin.matches.map((m) => [m.text, m.columnStart])).toEqual([
|
||||
["K", 5],
|
||||
["k", 7],
|
||||
["K", 9],
|
||||
]);
|
||||
const sigma = await searchWorkspaceContent({ cwd, query: "σ" });
|
||||
expect(sigma.status === "ok" && sigma.matches.map((m) => m.text)).toEqual(["σ", "Σ", "ς"]);
|
||||
const binary = await searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(binary.status === "ok" && binary.matches.map((m) => m.path)).toEqual(["long"]);
|
||||
});
|
||||
|
||||
test("bounds escaped output and reports timeouts and invalid single-line queries", async () => {
|
||||
const cwd = await workspace({ escaped: "\t".repeat(600_000) + "needle" });
|
||||
expect(await searchWorkspaceContent({ cwd, query: "needle" })).toMatchObject({
|
||||
status: "ok",
|
||||
limited: true,
|
||||
});
|
||||
expect(await searchWorkspaceContent({ cwd, query: "x\ny" })).toMatchObject({
|
||||
status: "error",
|
||||
code: "invalid_query",
|
||||
});
|
||||
expect(await searchWorkspaceContent({ cwd, query: "needle" }, { timeoutMs: 0 })).toMatchObject({
|
||||
status: "error",
|
||||
code: "timeout",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses source lines across CR-only and mixed separators, including unmatched preceding lines", async () => {
|
||||
const cwd = await workspace({
|
||||
"cr.txt": "first\rneedle",
|
||||
"mixed.txt": "unmatched\rprefix\nfirst\r🙂 NEEDLE\r\nend",
|
||||
});
|
||||
const result = await searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(
|
||||
result.status === "ok" &&
|
||||
result.matches.map(({ path: filePath, line, columnStart, columnEnd, text }) => ({
|
||||
path: filePath,
|
||||
line,
|
||||
columnStart,
|
||||
columnEnd,
|
||||
text,
|
||||
})),
|
||||
).toEqual([
|
||||
{ path: "cr.txt", line: 2, columnStart: 1, columnEnd: 7, text: "needle" },
|
||||
{ path: "mixed.txt", line: 4, columnStart: 4, columnEnd: 10, text: "NEEDLE" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("validates UTF-8 beyond the matching record and preserves an embedded BOM character", async () => {
|
||||
const cwd = await workspace({ "valid.txt": "prefix \uFEFFneedle" });
|
||||
await writeFile(
|
||||
path.join(cwd, "invalid.txt"),
|
||||
Buffer.from([110, 101, 101, 100, 108, 101, 10, 255]),
|
||||
);
|
||||
const invalid = await searchWorkspaceContent({ cwd, query: "needle" });
|
||||
expect(invalid.status === "ok" && invalid.matches.map((match) => match.path)).toEqual([
|
||||
"valid.txt",
|
||||
]);
|
||||
const embedded = await searchWorkspaceContent({ cwd, query: "\uFEFFneedle" });
|
||||
expect(
|
||||
embedded.status === "ok" && embedded.matches.map((match) => [match.text, match.columnStart]),
|
||||
).toEqual([["\uFEFFneedle", 8]]);
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import type {
|
||||
WorkspaceContentMatch,
|
||||
WorkspaceContentSearchResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { readExplorerFileBytes } from "../file-explorer/service.js";
|
||||
import { spawnProcess } from "../../utils/spawn.js";
|
||||
|
||||
const MAX_RESULTS = 200;
|
||||
const MAX_FILE_BYTES = 1_048_576;
|
||||
const MAX_OUTPUT_BYTES = 4 * MAX_FILE_BYTES;
|
||||
|
||||
/** Searches the host's saved files; owns rg and every resource it allocates. */
|
||||
export async function searchWorkspaceContent(
|
||||
input: { cwd: string; query: string; signal?: AbortSignal },
|
||||
options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
|
||||
): Promise<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 };
|
||||
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 timeout = setTimeout(() => {
|
||||
error = failure("timeout", "Search timed out — refine your query or retry");
|
||||
stop();
|
||||
}, options.timeoutMs ?? 5000);
|
||||
input.signal?.addEventListener("abort", abort, { once: true });
|
||||
if (input.signal?.aborted) abort();
|
||||
child.on("error", (cause) => {
|
||||
error ??= spawnFailure(cause);
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr = (stderr + chunk.toString("utf8")).slice(0, 8192);
|
||||
});
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
if (stopped) return;
|
||||
outputBytes += chunk.byteLength;
|
||||
if (outputBytes > MAX_OUTPUT_BYTES) {
|
||||
limited = true;
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
let end: number;
|
||||
while ((end = buffer.indexOf(10)) !== -1) {
|
||||
if (stopped) break;
|
||||
const record = buffer.subarray(0, end);
|
||||
buffer = buffer.subarray(end + 1);
|
||||
if (record.byteLength > MAX_FILE_BYTES) {
|
||||
limited = true;
|
||||
stop();
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const event = JSON.parse(record.toString("utf8")) as RgEvent;
|
||||
if (event.type === "begin") {
|
||||
pending = [];
|
||||
pendingPath = event.data.path?.text?.replace(/^\.\//, "") ?? "";
|
||||
} else if (event.type === "match" && pendingPath) {
|
||||
pending.push(
|
||||
...readMatches(
|
||||
event.data,
|
||||
pendingPath,
|
||||
MAX_RESULTS + 1 - matches.length - pending.length,
|
||||
),
|
||||
);
|
||||
} else if (event.type === "end") {
|
||||
// rg may emit matches before discovering a NUL byte later in the file.
|
||||
if (event.data.binary_offset == null) matches.push(...pending);
|
||||
pending = [];
|
||||
if (matches.length > MAX_RESULTS) {
|
||||
limited = true;
|
||||
stop();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
error = failure("unavailable", "Could not read ripgrep search results");
|
||||
stop();
|
||||
}
|
||||
}
|
||||
if (buffer.byteLength > MAX_FILE_BYTES) {
|
||||
limited = true;
|
||||
stop();
|
||||
}
|
||||
});
|
||||
const cleanup = () => {
|
||||
for (const timer of [timeout, killTimer]) clearTimeout(timer);
|
||||
input.signal?.removeEventListener("abort", abort);
|
||||
};
|
||||
child.once("close", async (code) => {
|
||||
if (!error && !limited && code !== 0 && code !== 1)
|
||||
error = failure("unavailable", stderr.trim() || "File search failed");
|
||||
let located: WorkspaceContentMatch[] = [];
|
||||
try {
|
||||
if (!error)
|
||||
located = await locateMatches(input.cwd, matches.slice(0, MAX_RESULTS), () => !!error);
|
||||
} catch {
|
||||
error ??= failure("unavailable", "Files changed while searching — retry");
|
||||
}
|
||||
cleanup();
|
||||
resolve(error ?? { status: "ok", matches: located, limited });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface RgEvent {
|
||||
type: string;
|
||||
data: {
|
||||
path?: { text?: string };
|
||||
lines?: { text?: string };
|
||||
line_number?: number;
|
||||
submatches?: { start: number; end: number }[];
|
||||
binary_offset?: number | null;
|
||||
absolute_offset?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function readMatches(data: RgEvent["data"], path: string, remaining: number): RgMatch[] {
|
||||
if (!data.lines?.text || !data.line_number || remaining <= 0) return [];
|
||||
const raw = Buffer.from(data.lines.text);
|
||||
return (data.submatches ?? []).slice(0, remaining).map(({ start, end }) => ({
|
||||
path,
|
||||
byteOffset: (data.absolute_offset ?? 0) + start,
|
||||
text: raw.subarray(start, end).toString("utf8"),
|
||||
}));
|
||||
}
|
||||
|
||||
function failure(
|
||||
code: Extract<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;
|
||||
}
|
||||
|
||||
/** rg counts LF records; source views count CR, CRLF and LF lines. Read each matched
|
||||
* file once through the bounded/revision-checked producer to translate original byte
|
||||
* offsets. A file changed during the search must not manufacture an occurrence. */
|
||||
async function locateMatches(
|
||||
cwd: string,
|
||||
matches: RgMatch[],
|
||||
stopped: () => boolean,
|
||||
): Promise<WorkspaceContentMatch[]> {
|
||||
const located: WorkspaceContentMatch[] = [];
|
||||
let path = "";
|
||||
let bytes: Uint8Array = new Uint8Array();
|
||||
let valid = false;
|
||||
for (const { byteOffset, ...match } of matches) {
|
||||
if (stopped()) break;
|
||||
if (path !== match.path) {
|
||||
path = match.path;
|
||||
const file = await readExplorerFileBytes({
|
||||
root: cwd,
|
||||
relativePath: path,
|
||||
maxBytes: MAX_FILE_BYTES,
|
||||
});
|
||||
bytes = file.bytes;
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
valid = !bytes.includes(0);
|
||||
} catch {
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
if (!valid) continue;
|
||||
const end = byteOffset + Buffer.byteLength(match.text);
|
||||
if (
|
||||
new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes.subarray(byteOffset, end)) !==
|
||||
match.text
|
||||
)
|
||||
continue;
|
||||
const prefix = new TextDecoder().decode(bytes.subarray(0, byteOffset)).split(/\r\n|\r|\n/);
|
||||
const column = prefix[prefix.length - 1].length;
|
||||
const line =
|
||||
prefix[prefix.length - 1] +
|
||||
new TextDecoder("utf-8", { ignoreBOM: true })
|
||||
.decode(bytes.subarray(byteOffset))
|
||||
.split(/\r\n|\r|\n/, 1)[0];
|
||||
const snippetStart = Math.max(0, column - 80);
|
||||
const snippet = line.slice(snippetStart, snippetStart + 240);
|
||||
located.push({
|
||||
...match,
|
||||
line: prefix.length,
|
||||
columnStart: column + 1,
|
||||
columnEnd: column + match.text.length + 1,
|
||||
snippet,
|
||||
snippetMatchStart: column - snippetStart,
|
||||
snippetMatchEnd: Math.min(column + match.text.length - snippetStart, snippet.length),
|
||||
});
|
||||
}
|
||||
return located;
|
||||
}
|
||||
Reference in New Issue
Block a user