mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Edit workspace files directly on web (#2270)
* feat(files): edit workspace files on web Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge. * feat(panels): surface and protect modified tabs Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently. * fix(tests): use portable fake timeout handle * fix(files): harden editor conflict handling Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface. * fix(files): close editor concurrency gaps Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads. * test(files): expect read revision metadata
This commit is contained in:
@@ -64,6 +64,8 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
|||||||
|
|
||||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||||
|
|
||||||
|
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.xs` labels, `sm` = 32px with `fontSize.sm`, `md`/`lg` = 44px with `fontSize.sm`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and horizontal padding. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||||
|
|
||||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
77
package-lock.json
generated
77
package-lock.json
generated
@@ -2531,10 +2531,35 @@
|
|||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/autocomplete": {
|
||||||
|
"version": "6.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||||
|
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/commands": {
|
||||||
|
"version": "6.10.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
|
||||||
|
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.7.0",
|
||||||
|
"@codemirror/view": "^6.27.0",
|
||||||
|
"@lezer/common": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/language": {
|
"node_modules/@codemirror/language": {
|
||||||
"version": "6.12.3",
|
"version": "6.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -2554,22 +2579,33 @@
|
|||||||
"@codemirror/language": "^6.0.0"
|
"@codemirror/language": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/search": {
|
||||||
|
"version": "6.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
|
||||||
|
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.37.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/state": {
|
"node_modules/@codemirror/state": {
|
||||||
"version": "6.6.0",
|
"version": "6.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marijn/find-cluster-break": "^1.0.0"
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/view": {
|
"node_modules/@codemirror/view": {
|
||||||
"version": "6.43.0",
|
"version": "6.43.6",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
|
||||||
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.7.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
"style-mod": "^4.1.0",
|
"style-mod": "^4.1.0",
|
||||||
"w3c-keyname": "^2.2.4"
|
"w3c-keyname": "^2.2.4"
|
||||||
@@ -11055,6 +11091,19 @@
|
|||||||
"@lezer/lr": "^1.0.0"
|
"@lezer/lr": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@replit/codemirror-vim": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@codemirror/commands": "6.x.x",
|
||||||
|
"@codemirror/language": "6.x.x",
|
||||||
|
"@codemirror/search": "6.x.x",
|
||||||
|
"@codemirror/state": "6.x.x",
|
||||||
|
"@codemirror/view": "6.x.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/pluginutils": {
|
"node_modules/@rollup/pluginutils": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||||
@@ -35164,6 +35213,11 @@
|
|||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "6.10.4",
|
||||||
|
"@codemirror/language": "6.12.4",
|
||||||
|
"@codemirror/search": "6.7.1",
|
||||||
|
"@codemirror/state": "6.7.1",
|
||||||
|
"@codemirror/view": "6.43.6",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -35178,6 +35232,7 @@
|
|||||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||||
"@react-native/normalize-colors": "^0.81.5",
|
"@react-native/normalize-colors": "^0.81.5",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"@replit/codemirror-vim": "6.3.0",
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||||
@@ -37587,7 +37642,7 @@
|
|||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.2.0-beta.1",
|
"version": "0.2.0-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
|
|||||||
@@ -132,6 +132,8 @@
|
|||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"@codemirror/language": "6.12.4",
|
||||||
|
"@codemirror/view": "6.43.6",
|
||||||
"lightningcss": "1.30.1",
|
"lightningcss": "1.30.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
|||||||
249
packages/app/e2e/file-editing.spec.ts
Normal file
249
packages/app/e2e/file-editing.spec.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test, type Page } from "./fixtures";
|
||||||
|
import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer";
|
||||||
|
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||||
|
|
||||||
|
const RED_PIXEL = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
const BLUE_PIXEL = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
|
||||||
|
function editor(page: Page) {
|
||||||
|
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceEditorText(page: Page, content: string): Promise<void> {
|
||||||
|
const contentElement = editor(page);
|
||||||
|
await contentElement.click();
|
||||||
|
await contentElement.press("Control+A");
|
||||||
|
await contentElement.type(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||||
|
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||||
|
if (!(await tree.isVisible())) await openFileExplorer(page);
|
||||||
|
await openFileFromExplorer(page, filename);
|
||||||
|
await expectFileTabOpen(page, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("CodeMirror workspace file editing", () => {
|
||||||
|
test("shows the full file path and keeps editor controls stable", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
await page.emulateMedia({ colorScheme: "dark" });
|
||||||
|
const workspace = await withWorkspace({ prefix: "file-editing-visuals-" });
|
||||||
|
const relativePath = "src/deep/visuals.md";
|
||||||
|
const sourcePath = path.join(workspace.repoPath, relativePath);
|
||||||
|
await mkdir(path.dirname(sourcePath), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
sourcePath,
|
||||||
|
[...Array.from({ length: 11 }, (_, index) => `line ${index + 1}`), "abcdefghijklmnop"].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await page.getByTestId("file-explorer-tree-scroll").getByText("src", { exact: true }).click();
|
||||||
|
await page.getByTestId("file-explorer-tree-scroll").getByText("deep", { exact: true }).click();
|
||||||
|
await openFileFromExplorer(page, "visuals.md");
|
||||||
|
await expectFileTabOpen(page, relativePath);
|
||||||
|
|
||||||
|
const fileTab = page.getByTestId(`workspace-tab-file_${relativePath}`).first();
|
||||||
|
await fileTab.hover();
|
||||||
|
await expect(page.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)).toHaveText(
|
||||||
|
relativePath,
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
|
||||||
|
const modeControl = page.getByTestId("file-markdown-mode");
|
||||||
|
await expect(modeControl).toBeVisible();
|
||||||
|
await page.getByTestId("file-mode-source").click();
|
||||||
|
|
||||||
|
const editorHost = page.getByTestId("file-source-editor");
|
||||||
|
const content = editor(page);
|
||||||
|
await expect(editorHost).toHaveAttribute("data-pmono", "");
|
||||||
|
await expect(content).toHaveCSS("font-family", /SFMono-Regular/);
|
||||||
|
|
||||||
|
await content.click();
|
||||||
|
const cursor = editorHost.locator(".cm-cursor-primary");
|
||||||
|
await expect(cursor).toBeVisible();
|
||||||
|
await expect(cursor).toHaveCSS("border-left-color", "rgb(250, 250, 250)");
|
||||||
|
|
||||||
|
const initialModeBox = await modeControl.boundingBox();
|
||||||
|
expect(initialModeBox).not.toBeNull();
|
||||||
|
const initialModeX = initialModeBox!.x;
|
||||||
|
await content.press("Control+End");
|
||||||
|
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
||||||
|
const movedModeBox = await modeControl.boundingBox();
|
||||||
|
expect(movedModeBox).not.toBeNull();
|
||||||
|
expect(movedModeBox!.x).toBe(initialModeX);
|
||||||
|
|
||||||
|
await content.press("Control+a");
|
||||||
|
const selection = editorHost.locator(".cm-selectionBackground").first();
|
||||||
|
await expect(selection).toBeVisible();
|
||||||
|
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const gate = await installDaemonWebSocketGate(page);
|
||||||
|
const workspace = await withWorkspace({ prefix: "file-editing-source-" });
|
||||||
|
const sourcePath = path.join(workspace.repoPath, "source.ts");
|
||||||
|
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||||
|
await Promise.all(
|
||||||
|
["one.ts", "two.ts", "three.ts", "four.ts"].map((fileName) =>
|
||||||
|
writeFile(path.join(workspace.repoPath, fileName), `// ${fileName}\n`, "utf8"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openWorkspaceFile(page, "source.ts");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/File size/)).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/lines/)).toBeVisible();
|
||||||
|
|
||||||
|
await replaceEditorText(page, "const autosaved = 2;\n");
|
||||||
|
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Editor status dirty")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Editor status clean")).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).not.toBeVisible();
|
||||||
|
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const autosaved = 2;\n");
|
||||||
|
|
||||||
|
await replaceEditorText(page, "const immediate = 3;\n");
|
||||||
|
await editor(page).press("Control+s");
|
||||||
|
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const immediate = 3;\n");
|
||||||
|
|
||||||
|
await writeFile(sourcePath, "const external = 4;\nconst line = 2;\n", "utf8");
|
||||||
|
await expect(editor(page)).toContainText("const external = 4;");
|
||||||
|
await expect(page.getByLabel("3 lines")).toBeVisible();
|
||||||
|
|
||||||
|
await replaceEditorText(page, "const localWins = 5;\n");
|
||||||
|
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
|
||||||
|
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||||
|
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
|
||||||
|
await openWorkspaceFile(page, fileName);
|
||||||
|
}
|
||||||
|
await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
|
||||||
|
await expect(editor(page)).toContainText("const localWins = 5;");
|
||||||
|
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
||||||
|
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
|
||||||
|
|
||||||
|
await replaceEditorText(page, "const discarded = 7;\n");
|
||||||
|
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
|
||||||
|
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||||
|
page.once("dialog", (dialog) => dialog.accept());
|
||||||
|
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||||
|
await expect(editor(page)).toContainText("const diskWins = 8;");
|
||||||
|
|
||||||
|
const subscriptionCount = gate.getClientRequestCount("fs.file.subscribe.request");
|
||||||
|
await gate.drop();
|
||||||
|
gate.restore();
|
||||||
|
await expect
|
||||||
|
.poll(() => gate.getClientRequestCount("fs.file.subscribe.request"), { timeout: 30_000 })
|
||||||
|
.toBeGreaterThan(subscriptionCount);
|
||||||
|
await writeFile(sourcePath, "const afterReconnect = 9;\n", "utf8");
|
||||||
|
await expect(editor(page)).toContainText("const afterReconnect = 9;");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
|
||||||
|
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
|
||||||
|
const sourcePath = path.join(workspace.repoPath, "draft.ts");
|
||||||
|
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openWorkspaceFile(page, "draft.ts");
|
||||||
|
|
||||||
|
await replaceEditorText(page, "const local = 2;\n");
|
||||||
|
await writeFile(sourcePath, "const external = 3;\n", "utf8");
|
||||||
|
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||||
|
|
||||||
|
let closePrompt = "";
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
closePrompt = dialog.message();
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
await page
|
||||||
|
.getByTestId("workspace-tab-file_draft.ts")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first()
|
||||||
|
.click({ button: "right" });
|
||||||
|
await page
|
||||||
|
.getByTestId("workspace-tab-context-file_draft.ts-close")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.click();
|
||||||
|
expect(closePrompt).toContain("Closing it will discard the draft.");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
|
||||||
|
const markdownPath = path.join(workspace.repoPath, "notes.md");
|
||||||
|
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||||
|
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||||
|
await writeFile(imagePath, RED_PIXEL);
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openWorkspaceFile(page, "notes.md");
|
||||||
|
|
||||||
|
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("file-markdown-mode")).toBeVisible();
|
||||||
|
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
||||||
|
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("file-mode-source").click();
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await replaceEditorText(page, "# Saved from source\n");
|
||||||
|
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||||
|
await page.getByTestId("file-mode-preview").click();
|
||||||
|
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await openWorkspaceFile(page, "pixel.png");
|
||||||
|
const image = page.getByTestId("workspace-file-pane").locator("img");
|
||||||
|
await expect(image).toBeVisible();
|
||||||
|
const initialSource = await image.getAttribute("src");
|
||||||
|
await writeFile(imagePath, BLUE_PIXEL);
|
||||||
|
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const workspace = await withWorkspace({ prefix: "file-editing-vim-" });
|
||||||
|
await writeFile(path.join(workspace.repoPath, "vim.ts"), "const vim = true;\n", "utf8");
|
||||||
|
|
||||||
|
await page.goto("/settings/editor");
|
||||||
|
const toggle = page.getByRole("switch", { name: "Vim keybindings" });
|
||||||
|
await expect(toggle).toBeVisible();
|
||||||
|
await toggle.click();
|
||||||
|
await expect(toggle).toBeChecked();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("switch", { name: "Vim keybindings" })).toBeChecked();
|
||||||
|
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openWorkspaceFile(page, "vim.ts");
|
||||||
|
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Line 1, column 1")).toBeVisible();
|
||||||
|
await editor(page).click();
|
||||||
|
await editor(page).press("i");
|
||||||
|
await expect(page.getByLabel("Vim mode INSERT")).toBeVisible();
|
||||||
|
await editor(page).press("Escape");
|
||||||
|
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ interface SavedSettingsHostInput {
|
|||||||
const SECTION_LABELS = {
|
const SECTION_LABELS = {
|
||||||
general: "General",
|
general: "General",
|
||||||
appearance: "Appearance",
|
appearance: "Appearance",
|
||||||
|
editor: "Editor",
|
||||||
shortcuts: "Shortcuts",
|
shortcuts: "Shortcuts",
|
||||||
integrations: "Integrations",
|
integrations: "Integrations",
|
||||||
permissions: "Permissions",
|
permissions: "Permissions",
|
||||||
|
|||||||
@@ -32,6 +32,11 @@
|
|||||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "6.10.4",
|
||||||
|
"@codemirror/language": "6.12.4",
|
||||||
|
"@codemirror/search": "6.7.1",
|
||||||
|
"@codemirror/state": "6.7.1",
|
||||||
|
"@codemirror/view": "6.43.6",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -46,6 +51,7 @@
|
|||||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||||
"@react-native/normalize-colors": "^0.81.5",
|
"@react-native/normalize-colors": "^0.81.5",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"@replit/codemirror-vim": "6.3.0",
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { SvgXml } from "react-native-svg";
|
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -25,7 +24,7 @@ import {
|
|||||||
MoreVertical,
|
MoreVertical,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||||
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
|
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||||
@@ -154,7 +153,7 @@ function TreeRowItem({
|
|||||||
<View style={styles.entryIcon}>
|
<View style={styles.entryIcon}>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (!isDirectory) {
|
if (!isDirectory) {
|
||||||
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
return <MaterialFileIcon fileName={entry.name} size={16} />;
|
||||||
}
|
}
|
||||||
if (loading) return <ActivityIndicator size="small" />;
|
if (loading) return <ActivityIndicator size="small" />;
|
||||||
return <TreeChevron expanded={isExpanded} />;
|
return <TreeChevron expanded={isExpanded} />;
|
||||||
|
|||||||
15
packages/app/src/components/material-file-icon.tsx
Normal file
15
packages/app/src/components/material-file-icon.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import { SvgXml } from "react-native-svg";
|
||||||
|
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||||
|
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||||
|
|
||||||
|
export function MaterialFileIcon({ fileName, size }: { fileName: string; size: number }) {
|
||||||
|
return <SvgXml xml={getFileIconSvg(fileName)} width={size} height={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMaterialFileIcon(fileName: string): ComponentType<PanelIconProps> {
|
||||||
|
function BoundMaterialFileIcon({ size }: PanelIconProps) {
|
||||||
|
return <MaterialFileIcon fileName={fileName} size={size} />;
|
||||||
|
}
|
||||||
|
return BoundMaterialFileIcon;
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
getWorkspacePaneDescriptors,
|
getWorkspacePaneDescriptors,
|
||||||
} from "@/screens/workspace/workspace-pane-state";
|
} from "@/screens/workspace/workspace-pane-state";
|
||||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||||
|
import { useModifiedPanelTabIds } from "@/panels/panel-instance-attributes";
|
||||||
import {
|
import {
|
||||||
WorkspacePaneContent,
|
WorkspacePaneContent,
|
||||||
type WorkspacePaneContentModel,
|
type WorkspacePaneContentModel,
|
||||||
@@ -934,11 +935,17 @@ function SplitPaneView({
|
|||||||
);
|
);
|
||||||
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
|
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
|
||||||
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
|
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
|
||||||
|
const modifiedPaneTabIds = useModifiedPanelTabIds({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
tabIds: paneTabIds,
|
||||||
|
});
|
||||||
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
|
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
|
||||||
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
|
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
|
||||||
const { mountedTabIds } = useMountedTabSet({
|
const { mountedTabIds } = useMountedTabSet({
|
||||||
activeTabId: activeTabDescriptor?.tabId ?? null,
|
activeTabId: activeTabDescriptor?.tabId ?? null,
|
||||||
allTabIds: paneTabIds,
|
allTabIds: paneTabIds,
|
||||||
|
retainedTabIds: modifiedPaneTabIds,
|
||||||
cap: 3,
|
cap: 3,
|
||||||
});
|
});
|
||||||
const mountedPaneTabIds = useMemo(
|
const mountedPaneTabIds = useMemo(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const theme = {
|
|||||||
md: 6,
|
md: 6,
|
||||||
lg: 8,
|
lg: 8,
|
||||||
xl: 12,
|
xl: 12,
|
||||||
|
full: 9999,
|
||||||
},
|
},
|
||||||
borderWidth: {
|
borderWidth: {
|
||||||
1: 1,
|
1: 1,
|
||||||
@@ -102,12 +103,41 @@ describe("control geometry", () => {
|
|||||||
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("subtracts segmented control inset from the nested segment radius", () => {
|
it("keeps segmented controls ghost with fully rounded segments in a button-sized track", () => {
|
||||||
const geometry = createControlGeometry(theme);
|
const geometry = createControlGeometry(theme);
|
||||||
|
|
||||||
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
|
expect(geometry.segmentedContainerXs.padding).toBe(0);
|
||||||
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
|
expect(geometry.segmentedContainerSm.padding).toBe(0);
|
||||||
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
|
expect(geometry.segmentedContainerMd.padding).toBe(0);
|
||||||
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
|
expect(geometry.segmentedSegmentXs.borderRadius).toBe(9999);
|
||||||
|
expect(geometry.segmentedSegmentSm.borderRadius).toBe(9999);
|
||||||
|
expect(geometry.segmentedSegmentMd.borderRadius).toBe(9999);
|
||||||
|
expect(geometry.segmentedContainerXs.minHeight).toBe(geometry.buttonXs.minHeight);
|
||||||
|
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
|
||||||
|
expect(geometry.segmentedContainerMd.minHeight).toBe(geometry.buttonMd.minHeight);
|
||||||
|
expect(geometry.segmentedSegmentXs.minHeight).toBe(24);
|
||||||
|
expect(geometry.segmentedSegmentSm.minHeight).toBe(28);
|
||||||
|
expect(geometry.segmentedSegmentMd.minHeight).toBe(38);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one size contract across buttons and segmented controls", () => {
|
||||||
|
const geometry = createControlGeometry(theme);
|
||||||
|
|
||||||
|
// xs is a genuinely smaller tier, not sm with a different font.
|
||||||
|
expect(geometry.buttonXs.minHeight).toBe(28);
|
||||||
|
expect(geometry.buttonSm.minHeight).toBe(32);
|
||||||
|
expect(geometry.buttonMd.minHeight).toBe(44);
|
||||||
|
|
||||||
|
// Same size name means the same label size on every control kind.
|
||||||
|
expect(geometry.segmentedLabelXs.fontSize).toBe(12);
|
||||||
|
expect(geometry.segmentedLabelXs.fontSize).toBe(geometry.buttonTextXs.fontSize);
|
||||||
|
expect(geometry.segmentedLabelSm.fontSize).toBe(14);
|
||||||
|
expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonText.fontSize);
|
||||||
|
expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonText.fontSize);
|
||||||
|
|
||||||
|
// Same size name means the same horizontal padding on every control kind.
|
||||||
|
expect(geometry.segmentedSegmentXs.paddingHorizontal).toBe(geometry.buttonXs.paddingHorizontal);
|
||||||
|
expect(geometry.segmentedSegmentSm.paddingHorizontal).toBe(geometry.buttonSm.paddingHorizontal);
|
||||||
|
expect(geometry.segmentedSegmentMd.paddingHorizontal).toBe(geometry.buttonMd.paddingHorizontal);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|||||||
|
|
||||||
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
|
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
|
||||||
export type FieldControlSize = "sm" | "md";
|
export type FieldControlSize = "sm" | "md";
|
||||||
export type SegmentedControlSize = "sm" | "md";
|
export type SegmentedControlSize = "xs" | "sm" | "md";
|
||||||
export type ControlInteractionPhase = "rest" | "hover" | "active";
|
export type ControlInteractionPhase = "rest" | "hover" | "active";
|
||||||
|
|
||||||
export interface ControlInteractionState {
|
export interface ControlInteractionState {
|
||||||
@@ -22,8 +22,10 @@ export interface ControlInteractionStyleMap {
|
|||||||
controlDisabled?: StyleProp<ViewStyle>;
|
controlDisabled?: StyleProp<ViewStyle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TIGHT_CONTROL_HEIGHT = 28;
|
||||||
const COMPACT_CONTROL_HEIGHT = 32;
|
const COMPACT_CONTROL_HEIGHT = 32;
|
||||||
const FIELD_CONTROL_HEIGHT = 44;
|
const FIELD_CONTROL_HEIGHT = 44;
|
||||||
|
const SEGMENTED_TIGHT_INSET = 2;
|
||||||
const SEGMENTED_COMPACT_INSET = 2;
|
const SEGMENTED_COMPACT_INSET = 2;
|
||||||
const SEGMENTED_FIELD_INSET = 3;
|
const SEGMENTED_FIELD_INSET = 3;
|
||||||
const SWITCH_TRACK_WIDTH = 34;
|
const SWITCH_TRACK_WIDTH = 34;
|
||||||
@@ -35,6 +37,7 @@ const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
|
|||||||
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
|
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
|
||||||
|
|
||||||
const controlHeights = {
|
const controlHeights = {
|
||||||
|
tight: TIGHT_CONTROL_HEIGHT,
|
||||||
compact: COMPACT_CONTROL_HEIGHT,
|
compact: COMPACT_CONTROL_HEIGHT,
|
||||||
field: FIELD_CONTROL_HEIGHT,
|
field: FIELD_CONTROL_HEIGHT,
|
||||||
};
|
};
|
||||||
@@ -47,6 +50,7 @@ export const buttonIconSize: Record<ButtonControlSize, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
|
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
|
||||||
|
xs: ICON_SIZE.xs,
|
||||||
sm: ICON_SIZE.sm,
|
sm: ICON_SIZE.sm,
|
||||||
md: ICON_SIZE.md,
|
md: ICON_SIZE.md,
|
||||||
};
|
};
|
||||||
@@ -58,10 +62,6 @@ export const switchGeometry = {
|
|||||||
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
|
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
|
||||||
};
|
};
|
||||||
|
|
||||||
function nestedRadius(containerRadius: number, inset: number): number {
|
|
||||||
return Math.max(0, containerRadius - inset);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldLineHeight(fontSize: number): number {
|
function fieldLineHeight(fontSize: number): number {
|
||||||
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
||||||
}
|
}
|
||||||
@@ -121,8 +121,6 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
lineHeight: fieldTextMdLineHeight,
|
lineHeight: fieldTextMdLineHeight,
|
||||||
};
|
};
|
||||||
const segmentedContainerSmRadius = theme.borderRadius.md;
|
|
||||||
const segmentedContainerMdRadius = theme.borderRadius.lg;
|
|
||||||
const switchControl = {
|
const switchControl = {
|
||||||
minHeight: controlHeights.compact,
|
minHeight: controlHeights.compact,
|
||||||
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
||||||
@@ -130,7 +128,7 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
buttonXs: {
|
buttonXs: {
|
||||||
minHeight: controlHeights.compact,
|
minHeight: controlHeights.tight,
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
@@ -194,31 +192,41 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
opacity: theme.opacity[50],
|
opacity: theme.opacity[50],
|
||||||
},
|
},
|
||||||
switchControl,
|
switchControl,
|
||||||
|
segmentedContainerXs: {
|
||||||
|
minHeight: controlHeights.tight,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
segmentedContainerSm: {
|
segmentedContainerSm: {
|
||||||
minHeight: controlHeights.compact,
|
minHeight: controlHeights.compact,
|
||||||
padding: SEGMENTED_COMPACT_INSET,
|
padding: 0,
|
||||||
borderRadius: segmentedContainerSmRadius,
|
|
||||||
},
|
},
|
||||||
segmentedContainerMd: {
|
segmentedContainerMd: {
|
||||||
minHeight: controlHeights.field,
|
minHeight: controlHeights.field,
|
||||||
padding: SEGMENTED_FIELD_INSET,
|
padding: 0,
|
||||||
borderRadius: segmentedContainerMdRadius,
|
},
|
||||||
|
segmentedSegmentXs: {
|
||||||
|
minHeight: controlHeights.tight - SEGMENTED_TIGHT_INSET * 2,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
},
|
},
|
||||||
segmentedSegmentSm: {
|
segmentedSegmentSm: {
|
||||||
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
|
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
|
borderRadius: theme.borderRadius.full,
|
||||||
},
|
},
|
||||||
segmentedSegmentMd: {
|
segmentedSegmentMd: {
|
||||||
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
|
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[4],
|
||||||
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
|
borderRadius: theme.borderRadius.full,
|
||||||
|
},
|
||||||
|
segmentedLabelXs: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
segmentedLabelSm: {
|
segmentedLabelSm: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
segmentedLabelMd: {
|
segmentedLabelMd: {
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function SegmentIcon({ icon, iconSize, iconColor }: SegmentIconProps) {
|
|||||||
|
|
||||||
const ThemedSegmentIcon = withUnistyles(SegmentIcon);
|
const ThemedSegmentIcon = withUnistyles(SegmentIcon);
|
||||||
|
|
||||||
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
|
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.surface0 });
|
||||||
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
|
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
export function SegmentedControl<T extends string>({
|
export function SegmentedControl<T extends string>({
|
||||||
@@ -53,9 +53,14 @@ export function SegmentedControl<T extends string>({
|
|||||||
style,
|
style,
|
||||||
testID,
|
testID,
|
||||||
}: SegmentedControlProps<T>) {
|
}: SegmentedControlProps<T>) {
|
||||||
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
|
const sizeStyles = {
|
||||||
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
|
||||||
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm },
|
||||||
|
md: { container: styles.containerMd, segment: styles.segmentMd, label: styles.labelMd },
|
||||||
|
}[size];
|
||||||
|
const containerSizeStyle = sizeStyles.container;
|
||||||
|
const segmentSizeStyle = sizeStyles.segment;
|
||||||
|
const labelSizeStyle = sizeStyles.label;
|
||||||
const iconSize = segmentedIconSize[size];
|
const iconSize = segmentedIconSize[size];
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
@@ -161,9 +166,12 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
return {
|
return {
|
||||||
container: {
|
container: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "stretch",
|
alignItems: "center",
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: "transparent",
|
||||||
gap: 2,
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
containerXs: {
|
||||||
|
...geometry.segmentedContainerXs,
|
||||||
},
|
},
|
||||||
containerSm: {
|
containerSm: {
|
||||||
...geometry.segmentedContainerSm,
|
...geometry.segmentedContainerSm,
|
||||||
@@ -178,6 +186,9 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
segmentXs: {
|
||||||
|
...geometry.segmentedSegmentXs,
|
||||||
|
},
|
||||||
segmentSm: {
|
segmentSm: {
|
||||||
...geometry.segmentedSegmentSm,
|
...geometry.segmentedSegmentSm,
|
||||||
},
|
},
|
||||||
@@ -185,18 +196,13 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
...geometry.segmentedSegmentMd,
|
...geometry.segmentedSegmentMd,
|
||||||
},
|
},
|
||||||
segmentSelected: {
|
segmentSelected: {
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.foreground,
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.08,
|
|
||||||
shadowRadius: 2,
|
|
||||||
elevation: 1,
|
|
||||||
},
|
},
|
||||||
segmentHover: {
|
segmentHover: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
segmentPressed: {
|
segmentPressed: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface3,
|
||||||
},
|
},
|
||||||
segmentDisabled: {
|
segmentDisabled: {
|
||||||
opacity: theme.opacity[50],
|
opacity: theme.opacity[50],
|
||||||
@@ -209,6 +215,9 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
|
labelXs: {
|
||||||
|
...geometry.segmentedLabelXs,
|
||||||
|
},
|
||||||
labelSm: {
|
labelSm: {
|
||||||
...geometry.segmentedLabelSm,
|
...geometry.segmentedLabelSm,
|
||||||
},
|
},
|
||||||
@@ -216,7 +225,7 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
...geometry.segmentedLabelMd,
|
...geometry.segmentedLabelMd,
|
||||||
},
|
},
|
||||||
labelSelected: {
|
labelSelected: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
178
packages/app/src/file-pane/bar.tsx
Normal file
178
packages/app/src/file-pane/bar.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import { FileConflictAlert } from "./conflict-alert";
|
||||||
|
import type { FileEditorStatus } from "./editor/model";
|
||||||
|
|
||||||
|
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
||||||
|
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
|
export function FilePanelBar({
|
||||||
|
size,
|
||||||
|
lineCount,
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
editorStatus,
|
||||||
|
cursor,
|
||||||
|
vimMode,
|
||||||
|
conflictUnavailable,
|
||||||
|
onOverwrite,
|
||||||
|
onReload,
|
||||||
|
}: {
|
||||||
|
size: number;
|
||||||
|
lineCount?: number;
|
||||||
|
mode?: "preview" | "source";
|
||||||
|
onModeChange?(mode: "preview" | "source"): void;
|
||||||
|
editorStatus?: FileEditorStatus;
|
||||||
|
cursor?: { line: number; column: number };
|
||||||
|
vimMode?: string | null;
|
||||||
|
conflictUnavailable?: boolean;
|
||||||
|
onOverwrite?(): void;
|
||||||
|
onReload?(): void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const markdownModes = [
|
||||||
|
{
|
||||||
|
value: "preview" as const,
|
||||||
|
label: t("panels.file.editor.preview"),
|
||||||
|
testID: "file-mode-preview",
|
||||||
|
},
|
||||||
|
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<View style={styles.chrome} testID="file-panel-bar">
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.metadata}>
|
||||||
|
<Text
|
||||||
|
style={styles.whisper}
|
||||||
|
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
|
||||||
|
>
|
||||||
|
{formatFileSize(size)}
|
||||||
|
</Text>
|
||||||
|
{lineCount !== undefined ? (
|
||||||
|
<Text
|
||||||
|
style={styles.whisper}
|
||||||
|
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
|
||||||
|
>
|
||||||
|
{t("panels.file.editor.lines", { count: lineCount })}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={styles.status}
|
||||||
|
accessibilityLabel={
|
||||||
|
editorStatus
|
||||||
|
? t("panels.file.editor.editorStatus", { status: editorStatus })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editorStatus === "dirty" ? (
|
||||||
|
<View
|
||||||
|
style={styles.dirtyDot}
|
||||||
|
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{editorStatus === "saving" ? (
|
||||||
|
<>
|
||||||
|
<ThemedSpinner size={14} uniProps={spinnerMapping} />
|
||||||
|
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{editorStatus === "error" ? (
|
||||||
|
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
|
||||||
|
) : null}
|
||||||
|
{editorStatus === "conflict" ? (
|
||||||
|
<Text style={styles.error}>{t("panels.file.editor.changedOnDisk")}</Text>
|
||||||
|
) : null}
|
||||||
|
{vimMode ? (
|
||||||
|
<Text
|
||||||
|
style={styles.vim}
|
||||||
|
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
|
||||||
|
>
|
||||||
|
{vimMode}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{cursor ? (
|
||||||
|
<Text
|
||||||
|
style={styles.whisper}
|
||||||
|
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
|
||||||
|
>
|
||||||
|
Ln {cursor.line}, Col {cursor.column}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{mode && onModeChange ? (
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
value={mode}
|
||||||
|
onValueChange={onModeChange}
|
||||||
|
testID="file-markdown-mode"
|
||||||
|
options={markdownModes}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{editorStatus === "conflict" && onOverwrite && onReload ? (
|
||||||
|
<View style={styles.notice}>
|
||||||
|
<FileConflictAlert
|
||||||
|
unavailable={conflictUnavailable ?? false}
|
||||||
|
onOverwrite={onOverwrite}
|
||||||
|
onReload={onReload}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(size: number): string {
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
chrome: {
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
minHeight: 32,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs },
|
||||||
|
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.xs },
|
||||||
|
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs },
|
||||||
|
dirtyDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
flexShrink: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
vim: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontFamily: theme.fontFamily.mono,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
notice: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[3] },
|
||||||
|
}));
|
||||||
34
packages/app/src/file-pane/conflict-alert.tsx
Normal file
34
packages/app/src/file-pane/conflict-alert.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Alert } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function FileConflictAlert({
|
||||||
|
unavailable,
|
||||||
|
onOverwrite,
|
||||||
|
onReload,
|
||||||
|
}: {
|
||||||
|
unavailable: boolean;
|
||||||
|
onOverwrite(): void;
|
||||||
|
onReload(): void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
variant="warning"
|
||||||
|
title={
|
||||||
|
unavailable
|
||||||
|
? t("panels.file.editor.unavailableTitle")
|
||||||
|
: t("panels.file.editor.changedOnDisk")
|
||||||
|
}
|
||||||
|
description={t("panels.file.editor.conflictDescription")}
|
||||||
|
testID="file-conflict-alert"
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm" onPress={onOverwrite} disabled={unavailable}>
|
||||||
|
{t("panels.file.editor.overwrite")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onPress={onReload} disabled={unavailable}>
|
||||||
|
{t("panels.file.editor.reload")}
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
packages/app/src/file-pane/editor/extensions.web.ts
Normal file
87
packages/app/src/file-pane/editor/extensions.web.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||||
|
import {
|
||||||
|
bracketMatching,
|
||||||
|
defaultHighlightStyle,
|
||||||
|
indentOnInput,
|
||||||
|
syntaxHighlighting,
|
||||||
|
} from "@codemirror/language";
|
||||||
|
import { searchKeymap } from "@codemirror/search";
|
||||||
|
import {
|
||||||
|
EditorView,
|
||||||
|
drawSelection,
|
||||||
|
highlightActiveLine,
|
||||||
|
keymap,
|
||||||
|
lineNumbers,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { createCodeMirrorHighlightStyle, type HighlightStyle } from "@getpaseo/highlight";
|
||||||
|
|
||||||
|
export interface EditorVisualTheme {
|
||||||
|
colorScheme: "light" | "dark";
|
||||||
|
background: string;
|
||||||
|
foreground: string;
|
||||||
|
cursor: string;
|
||||||
|
foregroundMuted: string;
|
||||||
|
border: string;
|
||||||
|
selection: string;
|
||||||
|
monoFont: string;
|
||||||
|
codeFontSize: number;
|
||||||
|
syntax: Record<HighlightStyle, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editorBaseExtensions(onSave: () => void) {
|
||||||
|
return [
|
||||||
|
lineNumbers(),
|
||||||
|
history(),
|
||||||
|
drawSelection(),
|
||||||
|
indentOnInput(),
|
||||||
|
bracketMatching(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||||
|
keymap.of([
|
||||||
|
{ key: "Mod-s", preventDefault: true, run: () => (onSave(), true) },
|
||||||
|
indentWithTab,
|
||||||
|
...defaultKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
...searchKeymap,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editorTheme(theme: EditorVisualTheme) {
|
||||||
|
return [
|
||||||
|
EditorView.theme(
|
||||||
|
{
|
||||||
|
"&": {
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
color: theme.foreground,
|
||||||
|
fontFamily: theme.monoFont,
|
||||||
|
fontSize: `${theme.codeFontSize}px`,
|
||||||
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
overflow: "auto",
|
||||||
|
fontFamily: theme.monoFont,
|
||||||
|
lineHeight: "1.45",
|
||||||
|
},
|
||||||
|
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
|
||||||
|
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
|
||||||
|
".cm-gutters": {
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
color: theme.foregroundMuted,
|
||||||
|
borderRight: `1px solid ${theme.border}`,
|
||||||
|
},
|
||||||
|
".cm-activeLine": { backgroundColor: "transparent" },
|
||||||
|
".cm-activeLineGutter": { backgroundColor: "transparent", color: theme.foreground },
|
||||||
|
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
|
||||||
|
backgroundColor: theme.selection,
|
||||||
|
},
|
||||||
|
".cm-selectionBackground, ::selection": {
|
||||||
|
backgroundColor: theme.selection,
|
||||||
|
},
|
||||||
|
"&.cm-focused": { outline: "none" },
|
||||||
|
},
|
||||||
|
{ dark: theme.colorScheme === "dark" },
|
||||||
|
),
|
||||||
|
syntaxHighlighting(createCodeMirrorHighlightStyle(theme.syntax)),
|
||||||
|
];
|
||||||
|
}
|
||||||
252
packages/app/src/file-pane/editor/model.test.ts
Normal file
252
packages/app/src/file-pane/editor/model.test.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
||||||
|
import {
|
||||||
|
FileEditorModel,
|
||||||
|
type FileEditorClock,
|
||||||
|
type FileEditorFile,
|
||||||
|
type FileEditorSession,
|
||||||
|
} from "./model";
|
||||||
|
|
||||||
|
class TestClock implements FileEditorClock {
|
||||||
|
private callback: (() => void) | null = null;
|
||||||
|
|
||||||
|
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
|
||||||
|
this.callback = callback;
|
||||||
|
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(): void {
|
||||||
|
this.callback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fire(): void {
|
||||||
|
const callback = this.callback;
|
||||||
|
this.callback = null;
|
||||||
|
callback?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileSession implements FileEditorSession {
|
||||||
|
file: FileEditorFile;
|
||||||
|
writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> = [];
|
||||||
|
nextWrite: FileWriteResult | Error | null = null;
|
||||||
|
private pendingWrite: Promise<FileWriteResult> | null = null;
|
||||||
|
private resolvePendingWrite: ((result: FileWriteResult) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(file: FileEditorFile) {
|
||||||
|
this.file = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(): Promise<FileEditorFile> {
|
||||||
|
return this.file;
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(input: {
|
||||||
|
content: string;
|
||||||
|
expectedModifiedAt: string;
|
||||||
|
expectedRevision?: string;
|
||||||
|
}): Promise<FileWriteResult> {
|
||||||
|
this.writes.push(input);
|
||||||
|
if (this.pendingWrite) return this.pendingWrite;
|
||||||
|
if (this.nextWrite instanceof Error) throw this.nextWrite;
|
||||||
|
if (this.nextWrite) return this.nextWrite;
|
||||||
|
return {
|
||||||
|
status: "written",
|
||||||
|
modifiedAt: "2026-07-18T00:00:01.000Z",
|
||||||
|
size: input.content.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
holdNextWrite(): void {
|
||||||
|
this.pendingWrite = new Promise((resolve) => {
|
||||||
|
this.resolvePendingWrite = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
finishHeldWrite(result: FileWriteResult): void {
|
||||||
|
this.resolvePendingWrite?.(result);
|
||||||
|
this.pendingWrite = null;
|
||||||
|
this.resolvePendingWrite = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready(
|
||||||
|
modifiedAt = "2026-07-18T00:00:00.000Z",
|
||||||
|
size = 3,
|
||||||
|
): Extract<FileVersion, { status: "ready" }> {
|
||||||
|
return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeModel() {
|
||||||
|
const file = { content: "one", version: ready() as Extract<FileVersion, { status: "ready" }> };
|
||||||
|
const session = new FileSession(file);
|
||||||
|
const clock = new TestClock();
|
||||||
|
return { model: new FileEditorModel({ file, session, clock }), session, clock };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FileEditorModel", () => {
|
||||||
|
test("tracks whether the current buffer differs from persisted content", async () => {
|
||||||
|
const { model } = makeModel();
|
||||||
|
|
||||||
|
expect(model.getSnapshot().modified).toBe(false);
|
||||||
|
model.edit("two");
|
||||||
|
expect(model.getSnapshot().modified).toBe(true);
|
||||||
|
model.edit("one");
|
||||||
|
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
|
||||||
|
|
||||||
|
model.edit("saved");
|
||||||
|
await model.save();
|
||||||
|
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adopts a precise revision for otherwise unchanged initial metadata", () => {
|
||||||
|
const { model } = makeModel();
|
||||||
|
|
||||||
|
model.receiveFileVersion({ ...ready(), revision: "precise-revision" });
|
||||||
|
|
||||||
|
expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a newer edit modified when an older save finishes", async () => {
|
||||||
|
const { model, session } = makeModel();
|
||||||
|
session.holdNextWrite();
|
||||||
|
model.edit("saving");
|
||||||
|
|
||||||
|
const save = model.save();
|
||||||
|
model.edit("newer edit");
|
||||||
|
session.finishHeldWrite({
|
||||||
|
status: "written",
|
||||||
|
modifiedAt: "2026-07-18T00:00:01.000Z",
|
||||||
|
size: 6,
|
||||||
|
});
|
||||||
|
await save;
|
||||||
|
|
||||||
|
expect(model.getSnapshot()).toMatchObject({
|
||||||
|
status: "dirty",
|
||||||
|
content: "newer edit",
|
||||||
|
modified: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("autosaves the latest edit after inactivity", async () => {
|
||||||
|
const { model, session, clock } = makeModel();
|
||||||
|
|
||||||
|
model.edit("two");
|
||||||
|
model.edit("three");
|
||||||
|
clock.fire();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(session.writes).toEqual([
|
||||||
|
{ content: "three", expectedModifiedAt: "2026-07-18T00:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
expect(model.getSnapshot().status).toBe("clean");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reloads a clean editor when the disk version changes", async () => {
|
||||||
|
const { model, session } = makeModel();
|
||||||
|
session.file = {
|
||||||
|
content: "external",
|
||||||
|
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
|
||||||
|
};
|
||||||
|
|
||||||
|
model.receiveFileVersion(session.file.version);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coalesces consecutive clean disk updates onto the latest reload", async () => {
|
||||||
|
const { model, session } = makeModel();
|
||||||
|
const reads: Array<(file: FileEditorFile) => void> = [];
|
||||||
|
session.read = () => new Promise((resolve) => reads.push(resolve));
|
||||||
|
const firstVersion = ready("2026-07-18T00:00:02.000Z", 5);
|
||||||
|
const latestVersion = ready("2026-07-18T00:00:03.000Z", 6);
|
||||||
|
|
||||||
|
model.receiveFileVersion(firstVersion);
|
||||||
|
model.receiveFileVersion(latestVersion);
|
||||||
|
reads[0]?.({ content: "first", version: firstVersion });
|
||||||
|
await Promise.resolve();
|
||||||
|
reads[1]?.({ content: "latest", version: latestVersion });
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves a dirty buffer and overwrites against the newest disk revision", async () => {
|
||||||
|
const { model, session } = makeModel();
|
||||||
|
model.edit("local");
|
||||||
|
model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4));
|
||||||
|
|
||||||
|
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" });
|
||||||
|
await model.overwrite();
|
||||||
|
|
||||||
|
expect(session.writes).toEqual([
|
||||||
|
{ content: "local", expectedModifiedAt: "2026-07-18T00:00:02.000Z" },
|
||||||
|
]);
|
||||||
|
expect(model.getSnapshot().status).toBe("clean");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reload discards a conflicted local buffer for the disk contents", async () => {
|
||||||
|
const { model, session } = makeModel();
|
||||||
|
model.edit("local");
|
||||||
|
const diskVersion = ready("2026-07-18T00:00:02.000Z", 4) as Extract<
|
||||||
|
FileVersion,
|
||||||
|
{ status: "ready" }
|
||||||
|
>;
|
||||||
|
session.file = { content: "disk", version: diskVersion };
|
||||||
|
model.receiveFileVersion(diskVersion);
|
||||||
|
|
||||||
|
await model.reload();
|
||||||
|
|
||||||
|
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports failed saves without losing the local buffer", async () => {
|
||||||
|
const { model, session } = makeModel();
|
||||||
|
session.nextWrite = new Error("disk full");
|
||||||
|
model.edit("important local work");
|
||||||
|
|
||||||
|
await model.save();
|
||||||
|
|
||||||
|
expect(model.getSnapshot()).toMatchObject({
|
||||||
|
status: "error",
|
||||||
|
content: "important local work",
|
||||||
|
error: "disk full",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a deletion conflicts with local changes and stops autosave", () => {
|
||||||
|
const { model, session, clock } = makeModel();
|
||||||
|
model.edit("local");
|
||||||
|
model.receiveFileVersion({ status: "missing", cwd: "/workspace", path: "file.ts" });
|
||||||
|
|
||||||
|
clock.fire();
|
||||||
|
|
||||||
|
expect(model.getSnapshot().status).toBe("conflict");
|
||||||
|
expect(session.writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispose cancels pending autosave", () => {
|
||||||
|
const { model, session, clock } = makeModel();
|
||||||
|
model.edit("local");
|
||||||
|
|
||||||
|
model.dispose();
|
||||||
|
clock.fire();
|
||||||
|
|
||||||
|
expect(session.writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("suspends a pending autosave while close confirmation is active", async () => {
|
||||||
|
const { model, session, clock } = makeModel();
|
||||||
|
model.edit("local");
|
||||||
|
|
||||||
|
const resume = model.suspendAutosave();
|
||||||
|
clock.fire();
|
||||||
|
expect(session.writes).toEqual([]);
|
||||||
|
|
||||||
|
resume();
|
||||||
|
clock.fire();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(session.writes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
302
packages/app/src/file-pane/editor/model.ts
Normal file
302
packages/app/src/file-pane/editor/model.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
|
export type FileEditorStatus = "loading" | "clean" | "dirty" | "saving" | "conflict" | "error";
|
||||||
|
|
||||||
|
export interface FileEditorSnapshot {
|
||||||
|
status: FileEditorStatus;
|
||||||
|
content: string;
|
||||||
|
modified: boolean;
|
||||||
|
version: FileVersion;
|
||||||
|
observedVersion: FileVersion;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileEditorFile {
|
||||||
|
content: string;
|
||||||
|
version: Extract<FileVersion, { status: "ready" }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileEditorSession {
|
||||||
|
read(): Promise<FileEditorFile>;
|
||||||
|
write(input: {
|
||||||
|
content: string;
|
||||||
|
expectedModifiedAt: string;
|
||||||
|
expectedRevision?: string;
|
||||||
|
}): Promise<FileWriteResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileEditorClock {
|
||||||
|
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
||||||
|
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemClock: FileEditorClock = {
|
||||||
|
setTimeout(callback, delay) {
|
||||||
|
return globalThis.setTimeout(callback, delay);
|
||||||
|
},
|
||||||
|
clearTimeout(handle) {
|
||||||
|
globalThis.clearTimeout(handle);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FileEditorModel {
|
||||||
|
private readonly session: FileEditorSession;
|
||||||
|
private readonly clock: FileEditorClock;
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private snapshot: FileEditorSnapshot;
|
||||||
|
private autosave: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private saveSequence = 0;
|
||||||
|
private disposed = false;
|
||||||
|
private observedWhileSaving: FileVersion | null = null;
|
||||||
|
private persistedContent: string;
|
||||||
|
|
||||||
|
constructor(input: {
|
||||||
|
file: FileEditorFile;
|
||||||
|
session: FileEditorSession;
|
||||||
|
clock?: FileEditorClock;
|
||||||
|
}) {
|
||||||
|
this.session = input.session;
|
||||||
|
this.clock = input.clock ?? systemClock;
|
||||||
|
this.persistedContent = input.file.content;
|
||||||
|
this.snapshot = {
|
||||||
|
status: "clean",
|
||||||
|
content: input.file.content,
|
||||||
|
modified: false,
|
||||||
|
version: input.file.version,
|
||||||
|
observedVersion: input.file.version,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe = (listener: () => void): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
getSnapshot = (): FileEditorSnapshot => this.snapshot;
|
||||||
|
|
||||||
|
edit(content: string): void {
|
||||||
|
if (this.disposed || content === this.snapshot.content) return;
|
||||||
|
const modified = content !== this.persistedContent;
|
||||||
|
let status: FileEditorStatus = modified ? "dirty" : "clean";
|
||||||
|
if (this.snapshot.status === "conflict" || this.snapshot.status === "loading") {
|
||||||
|
status = "conflict";
|
||||||
|
}
|
||||||
|
this.setSnapshot({ ...this.snapshot, status, content, modified, error: null });
|
||||||
|
if (status === "dirty") this.scheduleAutosave();
|
||||||
|
else this.clearAutosave();
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(): Promise<void> {
|
||||||
|
if (this.disposed || (this.snapshot.status !== "dirty" && this.snapshot.status !== "error")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.snapshot.observedVersion.status !== "ready") {
|
||||||
|
this.enterConflict(this.snapshot.observedVersion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.performWrite(this.snapshot.observedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
receiveFileVersion(version: FileVersion): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
if (sameVersion(version, this.snapshot.observedVersion)) {
|
||||||
|
if (
|
||||||
|
version.status === "ready" &&
|
||||||
|
this.snapshot.observedVersion.status === "ready" &&
|
||||||
|
version.revision &&
|
||||||
|
!this.snapshot.observedVersion.revision
|
||||||
|
) {
|
||||||
|
this.setSnapshot({
|
||||||
|
...this.snapshot,
|
||||||
|
version:
|
||||||
|
this.snapshot.version.status === "ready"
|
||||||
|
? { ...this.snapshot.version, revision: version.revision }
|
||||||
|
: this.snapshot.version,
|
||||||
|
observedVersion: version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setSnapshot({ ...this.snapshot, observedVersion: version });
|
||||||
|
if (this.snapshot.status === "saving") {
|
||||||
|
this.observedWhileSaving = version;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.snapshot.status === "clean" || this.snapshot.status === "loading") {
|
||||||
|
void this.reloadFromDisk(version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.enterConflict(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
async overwrite(): Promise<void> {
|
||||||
|
if (this.disposed || this.snapshot.status !== "conflict") return;
|
||||||
|
if (this.snapshot.observedVersion.status !== "ready") return;
|
||||||
|
await this.performWrite(this.snapshot.observedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
if (this.disposed) return;
|
||||||
|
await this.reloadFromDisk(this.snapshot.observedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.disposed = true;
|
||||||
|
this.saveSequence += 1;
|
||||||
|
this.clearAutosave();
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
suspendAutosave(): () => void {
|
||||||
|
const wasScheduled = this.autosave !== null;
|
||||||
|
this.clearAutosave();
|
||||||
|
let resumed = false;
|
||||||
|
return () => {
|
||||||
|
if (resumed || this.disposed) return;
|
||||||
|
resumed = true;
|
||||||
|
if (wasScheduled && this.snapshot.status === "dirty") this.scheduleAutosave();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performWrite(
|
||||||
|
expectedVersion: Extract<FileVersion, { status: "ready" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
this.clearAutosave();
|
||||||
|
const sequence = ++this.saveSequence;
|
||||||
|
const content = this.snapshot.content;
|
||||||
|
this.observedWhileSaving = null;
|
||||||
|
this.setSnapshot({ ...this.snapshot, status: "saving", error: null });
|
||||||
|
let result: FileWriteResult;
|
||||||
|
try {
|
||||||
|
result = await this.session.write({
|
||||||
|
content,
|
||||||
|
expectedModifiedAt: expectedVersion.modifiedAt,
|
||||||
|
expectedRevision: expectedVersion.revision,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (this.disposed || sequence !== this.saveSequence) return;
|
||||||
|
this.setSnapshot({
|
||||||
|
...this.snapshot,
|
||||||
|
status: "error",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.disposed || sequence !== this.saveSequence) return;
|
||||||
|
if (result.status === "error") {
|
||||||
|
this.setSnapshot({ ...this.snapshot, status: "error", error: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status === "conflict") {
|
||||||
|
this.enterConflict(result.version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const writtenVersion: FileVersion = {
|
||||||
|
status: "ready",
|
||||||
|
cwd: this.snapshot.version.cwd,
|
||||||
|
path: this.snapshot.version.path,
|
||||||
|
size: result.size,
|
||||||
|
modifiedAt: result.modifiedAt,
|
||||||
|
revision: result.revision,
|
||||||
|
};
|
||||||
|
const pending = this.observedWhileSaving;
|
||||||
|
this.observedWhileSaving = null;
|
||||||
|
this.persistedContent = content;
|
||||||
|
if (pending && !sameVersion(pending, writtenVersion)) {
|
||||||
|
this.setSnapshot({
|
||||||
|
...this.snapshot,
|
||||||
|
status: "conflict",
|
||||||
|
modified: this.snapshot.content !== this.persistedContent,
|
||||||
|
version: writtenVersion,
|
||||||
|
observedVersion: pending,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const modified = this.snapshot.content !== this.persistedContent;
|
||||||
|
this.setSnapshot({
|
||||||
|
...this.snapshot,
|
||||||
|
status: modified ? "dirty" : "clean",
|
||||||
|
modified,
|
||||||
|
version: writtenVersion,
|
||||||
|
observedVersion: writtenVersion,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
if (modified) this.scheduleAutosave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reloadFromDisk(version: FileVersion): Promise<void> {
|
||||||
|
this.clearAutosave();
|
||||||
|
if (version.status !== "ready") {
|
||||||
|
this.enterConflict(version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sequence = ++this.saveSequence;
|
||||||
|
this.setSnapshot({ ...this.snapshot, status: "loading", error: null });
|
||||||
|
try {
|
||||||
|
const file = await this.session.read();
|
||||||
|
if (this.disposed || sequence !== this.saveSequence || this.snapshot.status !== "loading") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.persistedContent = file.content;
|
||||||
|
this.setSnapshot({
|
||||||
|
status: "clean",
|
||||||
|
content: file.content,
|
||||||
|
modified: false,
|
||||||
|
version: file.version,
|
||||||
|
observedVersion: file.version,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (this.disposed || sequence !== this.saveSequence) return;
|
||||||
|
this.setSnapshot({
|
||||||
|
...this.snapshot,
|
||||||
|
status: "error",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enterConflict(version: FileVersion): void {
|
||||||
|
this.clearAutosave();
|
||||||
|
this.setSnapshot({
|
||||||
|
...this.snapshot,
|
||||||
|
status: "conflict",
|
||||||
|
modified: this.snapshot.content !== this.persistedContent,
|
||||||
|
observedVersion: version,
|
||||||
|
error: version.status === "error" ? version.error : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleAutosave(): void {
|
||||||
|
this.clearAutosave();
|
||||||
|
this.autosave = this.clock.setTimeout(() => {
|
||||||
|
this.autosave = null;
|
||||||
|
void this.save();
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAutosave(): void {
|
||||||
|
if (!this.autosave) return;
|
||||||
|
this.clock.clearTimeout(this.autosave);
|
||||||
|
this.autosave = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSnapshot(snapshot: FileEditorSnapshot): void {
|
||||||
|
this.snapshot = snapshot;
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameVersion(left: FileVersion, right: FileVersion): boolean {
|
||||||
|
if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path)
|
||||||
|
return false;
|
||||||
|
if (left.status === "ready" && right.status === "ready") {
|
||||||
|
if (left.revision && right.revision) return left.revision === right.revision;
|
||||||
|
return left.modifiedAt === right.modifiedAt && left.size === right.size;
|
||||||
|
}
|
||||||
|
if (left.status === "error" && right.status === "error") return left.error === right.error;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
33
packages/app/src/file-pane/editor/view.tsx
Normal file
33
packages/app/src/file-pane/editor/view.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import type { HighlightStyle } from "@getpaseo/highlight";
|
||||||
|
import type { FileEditorModel } from "./model";
|
||||||
|
|
||||||
|
export function FileEditorView(_props: {
|
||||||
|
model: FileEditorModel;
|
||||||
|
filename: string;
|
||||||
|
vimEnabled: boolean;
|
||||||
|
theme: {
|
||||||
|
background: string;
|
||||||
|
foreground: string;
|
||||||
|
foregroundMuted: string;
|
||||||
|
border: string;
|
||||||
|
selection: string;
|
||||||
|
monoFont: string;
|
||||||
|
codeFontSize: number;
|
||||||
|
syntax: Record<HighlightStyle, string>;
|
||||||
|
};
|
||||||
|
onCursorChange(position: { line: number; column: number }): void;
|
||||||
|
onVimModeChange(mode: string | null): void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.text}>Source editing is available on web and desktop.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||||
|
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||||
|
}));
|
||||||
124
packages/app/src/file-pane/editor/view.web.tsx
Normal file
124
packages/app/src/file-pane/editor/view.web.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||||
|
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||||
|
import { getCM, vim } from "@replit/codemirror-vim";
|
||||||
|
import type { FileEditorModel } from "./model";
|
||||||
|
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
|
||||||
|
|
||||||
|
interface FileEditorViewProps {
|
||||||
|
model: FileEditorModel;
|
||||||
|
filename: string;
|
||||||
|
vimEnabled: boolean;
|
||||||
|
theme: EditorVisualTheme;
|
||||||
|
onCursorChange(position: { line: number; column: number }): void;
|
||||||
|
onVimModeChange(mode: string | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageCompartment = new Compartment();
|
||||||
|
const themeCompartment = new Compartment();
|
||||||
|
const vimCompartment = new Compartment();
|
||||||
|
|
||||||
|
export function FileEditorView({
|
||||||
|
model,
|
||||||
|
filename,
|
||||||
|
vimEnabled,
|
||||||
|
theme,
|
||||||
|
onCursorChange,
|
||||||
|
onVimModeChange,
|
||||||
|
}: FileEditorViewProps) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||||
|
const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content });
|
||||||
|
const onCursorChangeRef = useRef(onCursorChange);
|
||||||
|
onCursorChangeRef.current = onCursorChange;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hostRef.current) return;
|
||||||
|
const values = initial.current;
|
||||||
|
const view = new EditorView({
|
||||||
|
parent: hostRef.current,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: values.content,
|
||||||
|
extensions: [
|
||||||
|
vimCompartment.of(values.vimEnabled ? vim() : []),
|
||||||
|
...editorBaseExtensions(() => void values.model.save()),
|
||||||
|
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
|
||||||
|
themeCompartment.of(editorTheme(values.theme)),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (
|
||||||
|
update.docChanged &&
|
||||||
|
!update.transactions.some((tr) => tr.annotation(remoteUpdate))
|
||||||
|
) {
|
||||||
|
values.model.edit(update.state.doc.toString());
|
||||||
|
}
|
||||||
|
if (update.selectionSet || update.docChanged) {
|
||||||
|
const head = update.state.selection.main.head;
|
||||||
|
const line = update.state.doc.lineAt(head);
|
||||||
|
onCursorChangeRef.current({ line: line.number, column: head - line.from + 1 });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
viewRef.current = view;
|
||||||
|
onCursorChangeRef.current({ line: 1, column: 1 });
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view || view.state.doc.toString() === snapshot.content) return;
|
||||||
|
const head = Math.min(view.state.selection.main.head, snapshot.content.length);
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: snapshot.content },
|
||||||
|
selection: { anchor: head },
|
||||||
|
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
|
||||||
|
});
|
||||||
|
}, [snapshot.content]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
viewRef.current?.dispatch({
|
||||||
|
effects: languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []),
|
||||||
|
});
|
||||||
|
}, [filename]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(editorTheme(theme)) });
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({ effects: vimCompartment.reconfigure(vimEnabled ? vim() : []) });
|
||||||
|
if (!vimEnabled) {
|
||||||
|
onVimModeChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cm = getCM(view);
|
||||||
|
if (!cm) return;
|
||||||
|
function handleModeChange(event: { mode?: string }) {
|
||||||
|
onVimModeChange((event.mode ?? "normal").toUpperCase());
|
||||||
|
}
|
||||||
|
cm.on("vim-mode-change", handleModeChange);
|
||||||
|
onVimModeChange("NORMAL");
|
||||||
|
return () => cm.off("vim-mode-change", handleModeChange);
|
||||||
|
}, [onVimModeChange, vimEnabled]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
data-pmono=""
|
||||||
|
data-testid="file-source-editor"
|
||||||
|
aria-label={`Source editor for ${filename}`}
|
||||||
|
style={HOST_STYLE}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteUpdate = Annotation.define<boolean>();
|
||||||
|
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||||
93
packages/app/src/file-pane/live-file.ts
Normal file
93
packages/app/src/file-pane/live-file.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import type { FileVersion } from "@getpaseo/protocol/messages";
|
||||||
|
import { useFetchQuery } from "@/data/query";
|
||||||
|
|
||||||
|
export function useLiveFile(input: {
|
||||||
|
client: DaemonClient | null;
|
||||||
|
serverId: string;
|
||||||
|
cwd: string | null;
|
||||||
|
path: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
liveUpdates: boolean;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [subscriptionReady, setSubscriptionReady] = useState(!input.liveUpdates);
|
||||||
|
const [version, setVersion] = useState<FileVersion | null>(null);
|
||||||
|
const latestVersion = useRef<FileVersion | null>(null);
|
||||||
|
const queryKey = useMemo(
|
||||||
|
() => ["workspaceFile", input.serverId, input.cwd, input.path] as const,
|
||||||
|
[input.cwd, input.path, input.serverId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestVersion.current = null;
|
||||||
|
setVersion(null);
|
||||||
|
const { client, cwd, path } = input;
|
||||||
|
if (!input.liveUpdates || !client || !cwd || !path || !input.enabled) {
|
||||||
|
setSubscriptionReady(!input.liveUpdates);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let disposed = false;
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
setSubscriptionReady(false);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const subscription = await client.subscribeFile({ cwd, path }, (next) => {
|
||||||
|
if (disposed) return;
|
||||||
|
latestVersion.current = next;
|
||||||
|
setVersion(next);
|
||||||
|
void queryClient.invalidateQueries({ queryKey });
|
||||||
|
});
|
||||||
|
if (disposed) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsubscribe = subscription.unsubscribe;
|
||||||
|
latestVersion.current = subscription.initial;
|
||||||
|
setVersion(subscription.initial);
|
||||||
|
setSubscriptionReady(true);
|
||||||
|
} catch {
|
||||||
|
if (!disposed) setSubscriptionReady(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
input.client,
|
||||||
|
input.cwd,
|
||||||
|
input.enabled,
|
||||||
|
input.liveUpdates,
|
||||||
|
input.path,
|
||||||
|
queryClient,
|
||||||
|
queryKey,
|
||||||
|
input.serverId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const query = useFetchQuery({
|
||||||
|
queryKey,
|
||||||
|
enabled: input.enabled && Boolean(input.client && input.cwd && input.path) && subscriptionReady,
|
||||||
|
queryFn: async (): Promise<FileReadResult> => {
|
||||||
|
if (!input.client || !input.cwd || !input.path) throw new Error("File unavailable.");
|
||||||
|
return input.client.readFile(input.cwd, input.path);
|
||||||
|
},
|
||||||
|
dataShape: "value",
|
||||||
|
staleTimeMs: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observed = latestVersion.current;
|
||||||
|
if (
|
||||||
|
query.data &&
|
||||||
|
observed?.status === "ready" &&
|
||||||
|
query.data.modifiedAt !== observed.modifiedAt
|
||||||
|
) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey });
|
||||||
|
}
|
||||||
|
}, [query.data, queryClient, queryKey]);
|
||||||
|
|
||||||
|
return { query, version };
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import React, { useEffect, useMemo, useRef } from "react";
|
import React, {
|
||||||
import { useQuery } from "@tanstack/react-query";
|
useCallback,
|
||||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from "react";
|
||||||
|
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import type { FileVersion } from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image as RNImage,
|
Image as RNImage,
|
||||||
@@ -8,7 +15,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -29,6 +36,14 @@ import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
|||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
||||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
|
import { useLiveFile } from "./live-file";
|
||||||
|
import { FilePanelBar } from "./bar";
|
||||||
|
import { FileEditorModel, type FileEditorFile } from "./editor/model";
|
||||||
|
import { FileEditorView } from "./editor/view";
|
||||||
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
|
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
@@ -45,6 +60,8 @@ interface FilePreviewBodyProps {
|
|||||||
imagePreviewUri: string | null;
|
imagePreviewUri: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -192,7 +209,7 @@ function FilePreviewBody({
|
|||||||
location,
|
location,
|
||||||
imagePreviewUri,
|
imagePreviewUri,
|
||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const { theme } = useUnistyles();
|
const theme = UnistylesRuntime.getTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const filePath = location.path;
|
const filePath = location.path;
|
||||||
const isMarkdownFile =
|
const isMarkdownFile =
|
||||||
@@ -370,8 +387,18 @@ export function FilePane({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
const [markdownMode, setMarkdownMode] = useState<"preview" | "source">("preview");
|
||||||
|
const [resolvedPreview, setResolvedPreview] = useState<{
|
||||||
|
key: string | null;
|
||||||
|
file: ExplorerFile | null;
|
||||||
|
imageAttachment: AttachmentMetadata | null;
|
||||||
|
}>({ key: null, file: null, imageAttachment: null });
|
||||||
|
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||||
|
const supportsEditing = useSessionStore(
|
||||||
|
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
|
||||||
|
);
|
||||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||||
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||||
const readTarget = useMemo(
|
const readTarget = useMemo(
|
||||||
@@ -390,53 +417,174 @@ export function FilePane({
|
|||||||
// from another window after an external edit. The gate lives in isFileQueryEnabled.
|
// from another window after an external edit. The gate lives in isFileQueryEnabled.
|
||||||
const isActive = useRetainedPanelActive();
|
const isActive = useRetainedPanelActive();
|
||||||
const isAppVisible = useAppActivelyVisible();
|
const isAppVisible = useAppActivelyVisible();
|
||||||
|
const enabled = isFileQueryEnabled({
|
||||||
const query = useQuery({
|
hasReadTarget: Boolean(client && readTarget),
|
||||||
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
|
isTabActive: isActive,
|
||||||
enabled: isFileQueryEnabled({
|
isAppVisible,
|
||||||
hasReadTarget: Boolean(client && readTarget),
|
|
||||||
isTabActive: isActive,
|
|
||||||
isAppVisible,
|
|
||||||
}),
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!client || !readTarget) {
|
|
||||||
return {
|
|
||||||
file: null as ExplorerFile | null,
|
|
||||||
error: t("workspace.terminal.hostDisconnected"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const file = await client.readFile(readTarget.cwd, readTarget.path);
|
|
||||||
const preview = await createFilePanePreview(file);
|
|
||||||
return {
|
|
||||||
file: preview.file,
|
|
||||||
imageAttachment: preview.imageAttachment,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
file: null,
|
|
||||||
imageAttachment: null,
|
|
||||||
error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
staleTime: 5_000,
|
|
||||||
refetchOnMount: true,
|
|
||||||
});
|
});
|
||||||
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
|
const { query, version } = useLiveFile({
|
||||||
|
client,
|
||||||
|
serverId,
|
||||||
|
cwd: readTarget?.cwd ?? null,
|
||||||
|
path: readTarget?.path ?? null,
|
||||||
|
enabled,
|
||||||
|
liveUpdates: supportsEditing,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
||||||
|
void (async () => {
|
||||||
|
const nextPreview = await createFilePanePreview(query.data ?? null);
|
||||||
|
if (active) setResolvedPreview({ key, ...nextPreview });
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [query.data, readTarget]);
|
||||||
|
|
||||||
|
useEffect(() => setMarkdownMode("preview"), [readTarget?.path]);
|
||||||
|
|
||||||
|
const previewKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
||||||
|
const preview = resolvedPreview.key === previewKey ? resolvedPreview.file : null;
|
||||||
|
const imagePreviewUri = useAttachmentPreviewUrl(
|
||||||
|
resolvedPreview.key === previewKey ? resolvedPreview.imageAttachment : null,
|
||||||
|
);
|
||||||
|
const isMarkdown = isMarkdownPreview(preview, location.path);
|
||||||
|
const editable = isEditableTextFile({
|
||||||
|
preview,
|
||||||
|
supportsEditing,
|
||||||
|
});
|
||||||
|
const lineCount =
|
||||||
|
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
|
||||||
|
const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilePanePresentation
|
||||||
|
serverId={serverId}
|
||||||
|
client={client}
|
||||||
|
readTarget={readTarget}
|
||||||
|
preview={preview}
|
||||||
|
version={version}
|
||||||
|
filename={getFileNameFromPath(location.path) ?? location.path}
|
||||||
|
markdownMode={isMarkdown ? markdownMode : undefined}
|
||||||
|
onMarkdownModeChange={isMarkdown ? setMarkdownMode : undefined}
|
||||||
|
lineCount={lineCount}
|
||||||
|
editable={editable}
|
||||||
|
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
isLoading={query.isFetching}
|
||||||
|
isMobile={isMobile}
|
||||||
|
location={location}
|
||||||
|
imagePreviewUri={imagePreviewUri}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarkdownPreview(preview: ExplorerFile | null, path: string): boolean {
|
||||||
|
return preview?.kind === "text" && isRenderedMarkdownFile(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileErrorMessage(error: unknown, fallback: string): string | null {
|
||||||
|
if (!error) return null;
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableTextFile(input: {
|
||||||
|
preview: ExplorerFile | null;
|
||||||
|
supportsEditing: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return Boolean(
|
||||||
|
isWeb &&
|
||||||
|
input.supportsEditing &&
|
||||||
|
input.preview?.kind === "text" &&
|
||||||
|
input.preview.size <= 1024 * 1024,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePanePresentation({
|
||||||
|
serverId,
|
||||||
|
client,
|
||||||
|
readTarget,
|
||||||
|
preview,
|
||||||
|
version,
|
||||||
|
filename,
|
||||||
|
markdownMode,
|
||||||
|
onMarkdownModeChange,
|
||||||
|
lineCount,
|
||||||
|
editable,
|
||||||
|
disconnectedMessage,
|
||||||
|
errorMessage,
|
||||||
|
isLoading,
|
||||||
|
isMobile,
|
||||||
|
location,
|
||||||
|
imagePreviewUri,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
client: DaemonClient | null;
|
||||||
|
readTarget: { cwd: string; path: string } | null;
|
||||||
|
preview: ExplorerFile | null;
|
||||||
|
version: FileVersion | null;
|
||||||
|
filename: string;
|
||||||
|
markdownMode?: "preview" | "source";
|
||||||
|
onMarkdownModeChange?: (mode: "preview" | "source") => void;
|
||||||
|
lineCount?: number;
|
||||||
|
editable: boolean;
|
||||||
|
disconnectedMessage: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
location: WorkspaceFileLocation;
|
||||||
|
imagePreviewUri: string | null;
|
||||||
|
}) {
|
||||||
|
if (!client && readTarget) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
|
<View style={styles.centerState}>
|
||||||
|
<Text style={styles.errorText}>{disconnectedMessage}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editable && client && readTarget && preview?.kind === "text") {
|
||||||
|
return (
|
||||||
|
<EditableFilePane
|
||||||
|
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
|
||||||
|
client={client}
|
||||||
|
cwd={readTarget.cwd}
|
||||||
|
path={readTarget.path}
|
||||||
|
preview={preview as TextExplorerFile}
|
||||||
|
version={version}
|
||||||
|
filename={filename}
|
||||||
|
mode={markdownMode}
|
||||||
|
onModeChange={onMarkdownModeChange}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isMobile={isMobile}
|
||||||
|
location={location}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="workspace-file-pane">
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
{query.data?.error ? (
|
{preview ? (
|
||||||
|
<FilePanelBar
|
||||||
|
size={preview.size}
|
||||||
|
lineCount={lineCount}
|
||||||
|
mode={markdownMode}
|
||||||
|
onModeChange={onMarkdownModeChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{errorMessage ? (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
<Text style={styles.errorText}>{query.data.error}</Text>
|
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<FilePreviewBody
|
<FilePreviewBody
|
||||||
preview={query.data?.file ?? null}
|
preview={preview}
|
||||||
isLoading={query.isFetching}
|
isLoading={isLoading}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
location={location}
|
location={location}
|
||||||
imagePreviewUri={imagePreviewUri}
|
imagePreviewUri={imagePreviewUri}
|
||||||
@@ -445,6 +593,173 @@ export function FilePane({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EditableFilePane({
|
||||||
|
client,
|
||||||
|
cwd,
|
||||||
|
path,
|
||||||
|
preview,
|
||||||
|
version,
|
||||||
|
filename,
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
isLoading,
|
||||||
|
isMobile,
|
||||||
|
location,
|
||||||
|
}: {
|
||||||
|
client: DaemonClient;
|
||||||
|
cwd: string;
|
||||||
|
path: string;
|
||||||
|
preview: TextExplorerFile;
|
||||||
|
version: FileVersion | null;
|
||||||
|
filename: string;
|
||||||
|
mode?: "preview" | "source";
|
||||||
|
onModeChange?: (mode: "preview" | "source") => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
location: WorkspaceFileLocation;
|
||||||
|
}) {
|
||||||
|
const { settings } = useAppSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
||||||
|
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
|
||||||
|
const session = useMemo(
|
||||||
|
() => ({
|
||||||
|
async read(): Promise<FileEditorFile> {
|
||||||
|
const file = await client.readFile(cwd, path);
|
||||||
|
if (file.kind !== "text") throw new Error("File is no longer text.");
|
||||||
|
return {
|
||||||
|
content: new TextDecoder().decode(file.bytes),
|
||||||
|
version: {
|
||||||
|
status: "ready",
|
||||||
|
cwd,
|
||||||
|
path,
|
||||||
|
size: file.size,
|
||||||
|
modifiedAt: file.modifiedAt,
|
||||||
|
revision: file.revision,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
|
||||||
|
return client.writeFile({ cwd, path, ...input });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[client, cwd, path],
|
||||||
|
);
|
||||||
|
const [model] = useState(
|
||||||
|
() =>
|
||||||
|
new FileEditorModel({
|
||||||
|
file: {
|
||||||
|
content: preview.content ?? "",
|
||||||
|
version: {
|
||||||
|
status: "ready",
|
||||||
|
cwd,
|
||||||
|
path,
|
||||||
|
size: preview.size,
|
||||||
|
modifiedAt: preview.modifiedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||||
|
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
|
||||||
|
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
|
||||||
|
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.colors.border,
|
||||||
|
theme.colors.foreground,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
|
theme.colors.surface0,
|
||||||
|
theme.colors.syntax,
|
||||||
|
theme.colors.terminal.cursor,
|
||||||
|
theme.colors.terminal.selectionBackground,
|
||||||
|
theme.colorScheme,
|
||||||
|
theme.fontFamily.mono,
|
||||||
|
theme.fontSize.code,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => model.dispose(), [model]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (version) model.receiveFileVersion(version);
|
||||||
|
}, [model, version]);
|
||||||
|
|
||||||
|
const handleReload = useCallback(() => {
|
||||||
|
void (async () => {
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: t("panels.file.editor.reloadTitle"),
|
||||||
|
message: t("panels.file.editor.reloadMessage"),
|
||||||
|
confirmLabel: t("panels.file.editor.reload"),
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
if (confirmed) void model.reload();
|
||||||
|
})();
|
||||||
|
}, [model, t]);
|
||||||
|
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
|
||||||
|
const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []);
|
||||||
|
const renderedPreview = useMemo<ExplorerFile>(
|
||||||
|
() => ({
|
||||||
|
...preview,
|
||||||
|
content: snapshot.content,
|
||||||
|
size: snapshot.version.status === "ready" ? snapshot.version.size : preview.size,
|
||||||
|
modifiedAt:
|
||||||
|
snapshot.version.status === "ready" ? snapshot.version.modifiedAt : preview.modifiedAt,
|
||||||
|
}),
|
||||||
|
[preview, snapshot.content, snapshot.version],
|
||||||
|
);
|
||||||
|
const showSource = mode !== "preview";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
|
<FilePanelBar
|
||||||
|
size={
|
||||||
|
snapshot.observedVersion.status === "ready" ? snapshot.observedVersion.size : preview.size
|
||||||
|
}
|
||||||
|
lineCount={snapshot.content.split("\n").length}
|
||||||
|
editorStatus={snapshot.status}
|
||||||
|
cursor={showSource ? cursor : undefined}
|
||||||
|
vimMode={showSource ? vimMode : null}
|
||||||
|
conflictUnavailable={snapshot.observedVersion.status !== "ready"}
|
||||||
|
onOverwrite={handleOverwrite}
|
||||||
|
onReload={handleReload}
|
||||||
|
mode={mode}
|
||||||
|
onModeChange={onModeChange}
|
||||||
|
/>
|
||||||
|
{showSource ? (
|
||||||
|
<FileEditorView
|
||||||
|
model={model}
|
||||||
|
filename={filename}
|
||||||
|
vimEnabled={settings.vimKeybindings}
|
||||||
|
theme={visualTheme}
|
||||||
|
onCursorChange={setCursor}
|
||||||
|
onVimModeChange={handleVimModeChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FilePreviewBody
|
||||||
|
preview={renderedPreview}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isMobile={isMobile}
|
||||||
|
location={location}
|
||||||
|
imagePreviewUri={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -191,6 +191,9 @@ export function useSettings<TSelected>(
|
|||||||
if (updates.toolCallDetailLevel !== undefined) {
|
if (updates.toolCallDetailLevel !== undefined) {
|
||||||
appUpdates.toolCallDetailLevel = updates.toolCallDetailLevel;
|
appUpdates.toolCallDetailLevel = updates.toolCallDetailLevel;
|
||||||
}
|
}
|
||||||
|
if (updates.vimKeybindings !== undefined) {
|
||||||
|
appUpdates.vimKeybindings = updates.vimKeybindings;
|
||||||
|
}
|
||||||
const promises: Promise<void>[] = [];
|
const promises: Promise<void>[] = [];
|
||||||
if (Object.keys(appUpdates).length > 0) {
|
if (Object.keys(appUpdates).length > 0) {
|
||||||
promises.push(appSettings.updateSettings(appUpdates));
|
promises.push(appSettings.updateSettings(appUpdates));
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface AppSettings {
|
|||||||
workspaceTitleSource: WorkspaceTitleSource;
|
workspaceTitleSource: WorkspaceTitleSource;
|
||||||
autoExpandReasoning: boolean;
|
autoExpandReasoning: boolean;
|
||||||
toolCallDetailLevel: ToolCallDetailLevel;
|
toolCallDetailLevel: ToolCallDetailLevel;
|
||||||
|
vimKeybindings: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings extends AppSettings {
|
export interface Settings extends AppSettings {
|
||||||
@@ -66,6 +67,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
|||||||
workspaceTitleSource: "title",
|
workspaceTitleSource: "title",
|
||||||
autoExpandReasoning: false,
|
autoExpandReasoning: false,
|
||||||
toolCallDetailLevel: "detailed",
|
toolCallDetailLevel: "detailed",
|
||||||
|
vimKeybindings: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||||
@@ -233,6 +235,9 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
|||||||
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
|
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
|
||||||
result.syntaxTheme = stored.syntaxTheme;
|
result.syntaxTheme = stored.syntaxTheme;
|
||||||
}
|
}
|
||||||
|
if (typeof stored.vimKeybindings === "boolean") {
|
||||||
|
result.vimKeybindings = stored.vimKeybindings;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
typeof stored.workspaceTitleSource === "string" &&
|
typeof stored.workspaceTitleSource === "string" &&
|
||||||
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
|
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "تحميل...",
|
loading: "تحميل...",
|
||||||
|
modified: "تغييرات غير محفوظة",
|
||||||
loadingAgentTitle: "جارٍ تحميل عنوان الوكيل",
|
loadingAgentTitle: "جارٍ تحميل عنوان الوكيل",
|
||||||
emptyPane: "لا توجد علامات تبويب في هذا الجزء.",
|
emptyPane: "لا توجد علامات تبويب في هذا الجزء.",
|
||||||
fallback: {
|
fallback: {
|
||||||
@@ -532,6 +533,13 @@ export const ar: TranslationResources = {
|
|||||||
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
|
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
|
unsavedTitle: "تغييرات غير محفوظة",
|
||||||
|
unsavedMessage:
|
||||||
|
"تحتوي علامة التبويب هذه على تغييرات غير محفوظة. سيؤدي إغلاقها إلى تجاهل المسودة.",
|
||||||
|
closeWithoutSaving: "إغلاق بدون حفظ",
|
||||||
|
closePaneTitle: "إغلاق اللوحة؟",
|
||||||
|
bulkUnsaved:
|
||||||
|
"تحتوي {{count}} علامة تبويب على تغييرات غير محفوظة. سيؤدي الإغلاق إلى تجاهل المسودات.",
|
||||||
close: "يغلق",
|
close: "يغلق",
|
||||||
cancel: "يلغي",
|
cancel: "يلغي",
|
||||||
archive: "أرشيف",
|
archive: "أرشيف",
|
||||||
@@ -1445,6 +1453,25 @@ export const ar: TranslationResources = {
|
|||||||
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
||||||
failedToLoad: "فشل تحميل الملف",
|
failedToLoad: "فشل تحميل الملف",
|
||||||
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
||||||
|
editor: {
|
||||||
|
fileSize: "حجم الملف {{size}}",
|
||||||
|
lines: "{{count}} سطر",
|
||||||
|
editorStatus: "حالة المحرر {{status}}",
|
||||||
|
unsavedChanges: "تغييرات غير محفوظة",
|
||||||
|
saving: "جارٍ الحفظ...",
|
||||||
|
saveFailed: "فشل الحفظ",
|
||||||
|
changedOnDisk: "تغيّر على القرص",
|
||||||
|
vimMode: "وضع Vim {{mode}}",
|
||||||
|
cursor: "السطر {{line}}، العمود {{column}}",
|
||||||
|
preview: "معاينة",
|
||||||
|
source: "المصدر",
|
||||||
|
unavailableTitle: "الملف غير متاح على القرص",
|
||||||
|
conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.",
|
||||||
|
overwrite: "استبدال",
|
||||||
|
reload: "إعادة تحميل",
|
||||||
|
reloadTitle: "إعادة التحميل من القرص؟",
|
||||||
|
reloadMessage: "ستفقد تغييراتك المحلية.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
changesLabel: "التغييرات",
|
changesLabel: "التغييرات",
|
||||||
@@ -1540,12 +1567,18 @@ export const ar: TranslationResources = {
|
|||||||
sections: {
|
sections: {
|
||||||
general: "عام",
|
general: "عام",
|
||||||
appearance: "مظهر",
|
appearance: "مظهر",
|
||||||
|
editor: "المحرر",
|
||||||
shortcuts: "الاختصارات",
|
shortcuts: "الاختصارات",
|
||||||
integrations: "التكامل",
|
integrations: "التكامل",
|
||||||
permissions: "الأذونات",
|
permissions: "الأذونات",
|
||||||
diagnostics: "التشخيص",
|
diagnostics: "التشخيص",
|
||||||
about: "عن",
|
about: "عن",
|
||||||
},
|
},
|
||||||
|
editor: {
|
||||||
|
title: "المحرر",
|
||||||
|
vimKeybindings: "اختصارات Vim",
|
||||||
|
vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
|
||||||
|
},
|
||||||
hostSections: {
|
hostSections: {
|
||||||
connections: "اتصالات",
|
connections: "اتصالات",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
|
|||||||
@@ -465,6 +465,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "Loading...",
|
loading: "Loading...",
|
||||||
|
modified: "Unsaved changes",
|
||||||
loadingAgentTitle: "Loading agent title",
|
loadingAgentTitle: "Loading agent title",
|
||||||
emptyPane: "No tabs in this pane.",
|
emptyPane: "No tabs in this pane.",
|
||||||
fallback: {
|
fallback: {
|
||||||
@@ -534,6 +535,12 @@ export const en = {
|
|||||||
close: "Close",
|
close: "Close",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
archive: "Archive",
|
archive: "Archive",
|
||||||
|
unsavedTitle: "Unsaved changes",
|
||||||
|
unsavedMessage:
|
||||||
|
"This tab has changes that have not been saved. Closing it will discard the draft.",
|
||||||
|
closeWithoutSaving: "Close without saving",
|
||||||
|
closePaneTitle: "Close pane?",
|
||||||
|
bulkUnsaved: "{{count}} tab(s) have unsaved changes. Closing will discard those drafts.",
|
||||||
closeTerminalTitle: "Close terminal?",
|
closeTerminalTitle: "Close terminal?",
|
||||||
closeTerminalMessage: "Any running process in this terminal will be stopped immediately.",
|
closeTerminalMessage: "Any running process in this terminal will be stopped immediately.",
|
||||||
archiveRunningAgentTitle: "Archive running agent?",
|
archiveRunningAgentTitle: "Archive running agent?",
|
||||||
@@ -1457,6 +1464,25 @@ export const en = {
|
|||||||
binaryPreviewUnavailable: "Binary preview unavailable",
|
binaryPreviewUnavailable: "Binary preview unavailable",
|
||||||
failedToLoad: "Failed to load file",
|
failedToLoad: "Failed to load file",
|
||||||
failedToLoadPreview: "Failed to load file preview",
|
failedToLoadPreview: "Failed to load file preview",
|
||||||
|
editor: {
|
||||||
|
fileSize: "File size {{size}}",
|
||||||
|
lines: "{{count}} lines",
|
||||||
|
editorStatus: "Editor status {{status}}",
|
||||||
|
unsavedChanges: "Unsaved changes",
|
||||||
|
saving: "Saving...",
|
||||||
|
saveFailed: "Save failed",
|
||||||
|
changedOnDisk: "Changed on disk",
|
||||||
|
vimMode: "Vim mode {{mode}}",
|
||||||
|
cursor: "Line {{line}}, column {{column}}",
|
||||||
|
preview: "Preview",
|
||||||
|
source: "Source",
|
||||||
|
unavailableTitle: "File unavailable on disk",
|
||||||
|
conflictDescription: "The local buffer was preserved. Choose which version to keep.",
|
||||||
|
overwrite: "Overwrite",
|
||||||
|
reload: "Reload",
|
||||||
|
reloadTitle: "Reload from disk?",
|
||||||
|
reloadMessage: "Your local changes will be lost.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
changesLabel: "Changes",
|
changesLabel: "Changes",
|
||||||
@@ -1552,12 +1578,18 @@ export const en = {
|
|||||||
sections: {
|
sections: {
|
||||||
general: "General",
|
general: "General",
|
||||||
appearance: "Appearance",
|
appearance: "Appearance",
|
||||||
|
editor: "Editor",
|
||||||
shortcuts: "Shortcuts",
|
shortcuts: "Shortcuts",
|
||||||
integrations: "Integrations",
|
integrations: "Integrations",
|
||||||
permissions: "Permissions",
|
permissions: "Permissions",
|
||||||
diagnostics: "Diagnostics",
|
diagnostics: "Diagnostics",
|
||||||
about: "About",
|
about: "About",
|
||||||
},
|
},
|
||||||
|
editor: {
|
||||||
|
title: "Editor",
|
||||||
|
vimKeybindings: "Vim keybindings",
|
||||||
|
vimHint: "Applies to source files on web and desktop.",
|
||||||
|
},
|
||||||
hostSections: {
|
hostSections: {
|
||||||
connections: "Connections",
|
connections: "Connections",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "Cargando...",
|
loading: "Cargando...",
|
||||||
|
modified: "Cambios sin guardar",
|
||||||
loadingAgentTitle: "Título del agente de carga",
|
loadingAgentTitle: "Título del agente de carga",
|
||||||
emptyPane: "No hay pestañas en este panel.",
|
emptyPane: "No hay pestañas en este panel.",
|
||||||
fallback: {
|
fallback: {
|
||||||
@@ -537,6 +538,13 @@ export const es: TranslationResources = {
|
|||||||
failedToReloadAgent: "No se pudo recargar el agente",
|
failedToReloadAgent: "No se pudo recargar el agente",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
|
unsavedTitle: "Cambios sin guardar",
|
||||||
|
unsavedMessage:
|
||||||
|
"Esta pestaña tiene cambios sin guardar. Al cerrarla se descartará el borrador.",
|
||||||
|
closeWithoutSaving: "Cerrar sin guardar",
|
||||||
|
closePaneTitle: "¿Cerrar panel?",
|
||||||
|
bulkUnsaved:
|
||||||
|
"{{count}} pestaña(s) tienen cambios sin guardar. Al cerrar se descartarán esos borradores.",
|
||||||
close: "Cerca",
|
close: "Cerca",
|
||||||
cancel: "Cancelar",
|
cancel: "Cancelar",
|
||||||
archive: "Archivo",
|
archive: "Archivo",
|
||||||
@@ -1488,6 +1496,25 @@ export const es: TranslationResources = {
|
|||||||
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
||||||
failedToLoad: "No se pudo cargar el archivo",
|
failedToLoad: "No se pudo cargar el archivo",
|
||||||
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
||||||
|
editor: {
|
||||||
|
fileSize: "Tamaño {{size}}",
|
||||||
|
lines: "{{count}} líneas",
|
||||||
|
editorStatus: "Estado del editor: {{status}}",
|
||||||
|
unsavedChanges: "Cambios sin guardar",
|
||||||
|
saving: "Guardando...",
|
||||||
|
saveFailed: "Error al guardar",
|
||||||
|
changedOnDisk: "Cambiado en disco",
|
||||||
|
vimMode: "Modo Vim {{mode}}",
|
||||||
|
cursor: "Línea {{line}}, columna {{column}}",
|
||||||
|
preview: "Vista previa",
|
||||||
|
source: "Código fuente",
|
||||||
|
unavailableTitle: "Archivo no disponible en disco",
|
||||||
|
conflictDescription: "Se conservó el búfer local. Elige qué versión mantener.",
|
||||||
|
overwrite: "Sobrescribir",
|
||||||
|
reload: "Recargar",
|
||||||
|
reloadTitle: "¿Recargar desde el disco?",
|
||||||
|
reloadMessage: "Se perderán tus cambios locales.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
changesLabel: "Cambios",
|
changesLabel: "Cambios",
|
||||||
@@ -1583,12 +1610,18 @@ export const es: TranslationResources = {
|
|||||||
sections: {
|
sections: {
|
||||||
general: "General",
|
general: "General",
|
||||||
appearance: "Apariencia",
|
appearance: "Apariencia",
|
||||||
|
editor: "Editor",
|
||||||
shortcuts: "Atajos",
|
shortcuts: "Atajos",
|
||||||
integrations: "Integraciones",
|
integrations: "Integraciones",
|
||||||
permissions: "Permisos",
|
permissions: "Permisos",
|
||||||
diagnostics: "Diagnóstico",
|
diagnostics: "Diagnóstico",
|
||||||
about: "Acerca de",
|
about: "Acerca de",
|
||||||
},
|
},
|
||||||
|
editor: {
|
||||||
|
title: "Editor",
|
||||||
|
vimKeybindings: "Atajos de Vim",
|
||||||
|
vimHint: "Se aplica a archivos fuente en web y escritorio.",
|
||||||
|
},
|
||||||
hostSections: {
|
hostSections: {
|
||||||
connections: "Conexiones",
|
connections: "Conexiones",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "Chargement...",
|
loading: "Chargement...",
|
||||||
|
modified: "Modifications non enregistrées",
|
||||||
loadingAgentTitle: "Titre d'agent de chargement",
|
loadingAgentTitle: "Titre d'agent de chargement",
|
||||||
emptyPane: "Aucun onglet dans ce volet.",
|
emptyPane: "Aucun onglet dans ce volet.",
|
||||||
fallback: {
|
fallback: {
|
||||||
@@ -537,6 +538,13 @@ export const fr: TranslationResources = {
|
|||||||
failedToReloadAgent: "Échec du rechargement de l'agent",
|
failedToReloadAgent: "Échec du rechargement de l'agent",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
|
unsavedTitle: "Modifications non enregistrées",
|
||||||
|
unsavedMessage:
|
||||||
|
"Cet onglet contient des modifications non enregistrées. Le fermer supprimera le brouillon.",
|
||||||
|
closeWithoutSaving: "Fermer sans enregistrer",
|
||||||
|
closePaneTitle: "Fermer le volet?",
|
||||||
|
bulkUnsaved:
|
||||||
|
"{{count}} onglet(s) contiennent des modifications non enregistrées. Les fermer supprimera ces brouillons.",
|
||||||
close: "Fermer",
|
close: "Fermer",
|
||||||
cancel: "Annuler",
|
cancel: "Annuler",
|
||||||
archive: "Archive",
|
archive: "Archive",
|
||||||
@@ -1491,6 +1499,25 @@ export const fr: TranslationResources = {
|
|||||||
binaryPreviewUnavailable: "Aperçu binaire indisponible",
|
binaryPreviewUnavailable: "Aperçu binaire indisponible",
|
||||||
failedToLoad: "Échec du chargement du fichier",
|
failedToLoad: "Échec du chargement du fichier",
|
||||||
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
|
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
|
||||||
|
editor: {
|
||||||
|
fileSize: "Taille {{size}}",
|
||||||
|
lines: "{{count}} lignes",
|
||||||
|
editorStatus: "État de l’éditeur : {{status}}",
|
||||||
|
unsavedChanges: "Modifications non enregistrées",
|
||||||
|
saving: "Enregistrement...",
|
||||||
|
saveFailed: "Échec de l’enregistrement",
|
||||||
|
changedOnDisk: "Modifié sur le disque",
|
||||||
|
vimMode: "Mode Vim {{mode}}",
|
||||||
|
cursor: "Ligne {{line}}, colonne {{column}}",
|
||||||
|
preview: "Aperçu",
|
||||||
|
source: "Source",
|
||||||
|
unavailableTitle: "Fichier indisponible sur le disque",
|
||||||
|
conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.",
|
||||||
|
overwrite: "Écraser",
|
||||||
|
reload: "Recharger",
|
||||||
|
reloadTitle: "Recharger depuis le disque ?",
|
||||||
|
reloadMessage: "Vos modifications locales seront perdues.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
changesLabel: "Modifications",
|
changesLabel: "Modifications",
|
||||||
@@ -1586,12 +1613,18 @@ export const fr: TranslationResources = {
|
|||||||
sections: {
|
sections: {
|
||||||
general: "Général",
|
general: "Général",
|
||||||
appearance: "Apparence",
|
appearance: "Apparence",
|
||||||
|
editor: "Éditeur",
|
||||||
shortcuts: "Raccourcis",
|
shortcuts: "Raccourcis",
|
||||||
integrations: "Intégrations",
|
integrations: "Intégrations",
|
||||||
permissions: "Autorisations",
|
permissions: "Autorisations",
|
||||||
diagnostics: "Diagnostic",
|
diagnostics: "Diagnostic",
|
||||||
about: "À propos",
|
about: "À propos",
|
||||||
},
|
},
|
||||||
|
editor: {
|
||||||
|
title: "Éditeur",
|
||||||
|
vimKeybindings: "Raccourcis Vim",
|
||||||
|
vimHint: "S’applique aux fichiers source sur le web et le bureau.",
|
||||||
|
},
|
||||||
hostSections: {
|
hostSections: {
|
||||||
connections: "Relations",
|
connections: "Relations",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "読み込み中...",
|
loading: "読み込み中...",
|
||||||
|
modified: "未保存の変更",
|
||||||
loadingAgentTitle: "エージェントタイトルを読み込み中",
|
loadingAgentTitle: "エージェントタイトルを読み込み中",
|
||||||
emptyPane: "このペインにタブがありません。",
|
emptyPane: "このペインにタブがありません。",
|
||||||
fallback: {
|
fallback: {
|
||||||
@@ -537,6 +538,11 @@ export const ja: TranslationResources = {
|
|||||||
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
|
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
|
unsavedTitle: "未保存の変更",
|
||||||
|
unsavedMessage: "このタブには未保存の変更があります。閉じると下書きが破棄されます。",
|
||||||
|
closeWithoutSaving: "保存せずに閉じる",
|
||||||
|
closePaneTitle: "ペインを閉じますか?",
|
||||||
|
bulkUnsaved: "{{count}} 個のタブに未保存の変更があります。閉じると下書きが破棄されます。",
|
||||||
close: "閉じる",
|
close: "閉じる",
|
||||||
cancel: "キャンセル",
|
cancel: "キャンセル",
|
||||||
archive: "アーカイブ",
|
archive: "アーカイブ",
|
||||||
@@ -1463,6 +1469,25 @@ export const ja: TranslationResources = {
|
|||||||
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
|
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
|
||||||
failedToLoad: "ファイルの読み込みに失敗しました",
|
failedToLoad: "ファイルの読み込みに失敗しました",
|
||||||
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
||||||
|
editor: {
|
||||||
|
fileSize: "ファイルサイズ {{size}}",
|
||||||
|
lines: "{{count}} 行",
|
||||||
|
editorStatus: "エディターの状態 {{status}}",
|
||||||
|
unsavedChanges: "未保存の変更",
|
||||||
|
saving: "保存中...",
|
||||||
|
saveFailed: "保存に失敗しました",
|
||||||
|
changedOnDisk: "ディスク上で変更されました",
|
||||||
|
vimMode: "Vim モード {{mode}}",
|
||||||
|
cursor: "{{line}} 行、{{column}} 列",
|
||||||
|
preview: "プレビュー",
|
||||||
|
source: "ソース",
|
||||||
|
unavailableTitle: "ディスク上のファイルを利用できません",
|
||||||
|
conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。",
|
||||||
|
overwrite: "上書き",
|
||||||
|
reload: "再読み込み",
|
||||||
|
reloadTitle: "ディスクから再読み込みしますか?",
|
||||||
|
reloadMessage: "ローカルの変更は失われます。",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
changesLabel: "変更",
|
changesLabel: "変更",
|
||||||
@@ -1558,12 +1583,18 @@ export const ja: TranslationResources = {
|
|||||||
sections: {
|
sections: {
|
||||||
general: "一般",
|
general: "一般",
|
||||||
appearance: "外観",
|
appearance: "外観",
|
||||||
|
editor: "エディター",
|
||||||
shortcuts: "ショートカット",
|
shortcuts: "ショートカット",
|
||||||
integrations: "連携",
|
integrations: "連携",
|
||||||
permissions: "権限",
|
permissions: "権限",
|
||||||
diagnostics: "診断",
|
diagnostics: "診断",
|
||||||
about: "アプリ情報",
|
about: "アプリ情報",
|
||||||
},
|
},
|
||||||
|
editor: {
|
||||||
|
title: "エディター",
|
||||||
|
vimKeybindings: "Vim キーバインド",
|
||||||
|
vimHint: "Web とデスクトップのソースファイルに適用されます。",
|
||||||
|
},
|
||||||
hostSections: {
|
hostSections: {
|
||||||
connections: "接続",
|
connections: "接続",
|
||||||
agents: "エージェント",
|
agents: "エージェント",
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "Carregando...",
|
loading: "Carregando...",
|
||||||
|
modified: "Alterações não salvas",
|
||||||
loadingAgentTitle: "Carregando título do agente",
|
loadingAgentTitle: "Carregando título do agente",
|
||||||
emptyPane: "Nenhuma aba neste painel.",
|
emptyPane: "Nenhuma aba neste painel.",
|
||||||
fallback: {
|
fallback: {
|
||||||
@@ -536,6 +537,12 @@ export const ptBR: TranslationResources = {
|
|||||||
failedToReloadAgent: "Falha ao recarregar agente",
|
failedToReloadAgent: "Falha ao recarregar agente",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
|
unsavedTitle: "Alterações não salvas",
|
||||||
|
unsavedMessage: "Esta aba tem alterações não salvas. Fechá-la descartará o rascunho.",
|
||||||
|
closeWithoutSaving: "Fechar sem salvar",
|
||||||
|
closePaneTitle: "Fechar painel?",
|
||||||
|
bulkUnsaved:
|
||||||
|
"{{count}} aba(s) têm alterações não salvas. Fechar descartará esses rascunhos.",
|
||||||
close: "Fechar",
|
close: "Fechar",
|
||||||
cancel: "Cancelar",
|
cancel: "Cancelar",
|
||||||
archive: "Arquivar",
|
archive: "Arquivar",
|
||||||
@@ -1475,6 +1482,25 @@ export const ptBR: TranslationResources = {
|
|||||||
binaryPreviewUnavailable: "Prévia binária indisponível",
|
binaryPreviewUnavailable: "Prévia binária indisponível",
|
||||||
failedToLoad: "Falha ao carregar arquivo",
|
failedToLoad: "Falha ao carregar arquivo",
|
||||||
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
||||||
|
editor: {
|
||||||
|
fileSize: "Tamanho {{size}}",
|
||||||
|
lines: "{{count}} linhas",
|
||||||
|
editorStatus: "Status do editor: {{status}}",
|
||||||
|
unsavedChanges: "Alterações não salvas",
|
||||||
|
saving: "Salvando...",
|
||||||
|
saveFailed: "Falha ao salvar",
|
||||||
|
changedOnDisk: "Alterado no disco",
|
||||||
|
vimMode: "Modo Vim {{mode}}",
|
||||||
|
cursor: "Linha {{line}}, coluna {{column}}",
|
||||||
|
preview: "Prévia",
|
||||||
|
source: "Fonte",
|
||||||
|
unavailableTitle: "Arquivo indisponível no disco",
|
||||||
|
conflictDescription: "O conteúdo local foi preservado. Escolha qual versão manter.",
|
||||||
|
overwrite: "Sobrescrever",
|
||||||
|
reload: "Recarregar",
|
||||||
|
reloadTitle: "Recarregar do disco?",
|
||||||
|
reloadMessage: "Suas alterações locais serão perdidas.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
changesLabel: "Alterações",
|
changesLabel: "Alterações",
|
||||||
@@ -1570,12 +1596,18 @@ export const ptBR: TranslationResources = {
|
|||||||
sections: {
|
sections: {
|
||||||
general: "Geral",
|
general: "Geral",
|
||||||
appearance: "Aparência",
|
appearance: "Aparência",
|
||||||
|
editor: "Editor",
|
||||||
shortcuts: "Atalhos",
|
shortcuts: "Atalhos",
|
||||||
integrations: "Integrações",
|
integrations: "Integrações",
|
||||||
permissions: "Permissões",
|
permissions: "Permissões",
|
||||||
diagnostics: "Diagnósticos",
|
diagnostics: "Diagnósticos",
|
||||||
about: "Sobre",
|
about: "Sobre",
|
||||||
},
|
},
|
||||||
|
editor: {
|
||||||
|
title: "Editor",
|
||||||
|
vimKeybindings: "Atalhos do Vim",
|
||||||
|
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
|
||||||
|
},
|
||||||
hostSections: {
|
hostSections: {
|
||||||
connections: "Conexões",
|
connections: "Conexões",
|
||||||
agents: "Agentes",
|
agents: "Agentes",
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "Загрузка...",
|
loading: "Загрузка...",
|
||||||
|
modified: "Несохранённые изменения",
|
||||||
loadingAgentTitle: "Название агента загрузки",
|
loadingAgentTitle: "Название агента загрузки",
|
||||||
emptyPane: "На этой панели нет вкладок.",
|
emptyPane: "На этой панели нет вкладок.",
|
||||||
fallback: {
|
fallback: {
|
||||||
@@ -536,6 +537,13 @@ export const ru: TranslationResources = {
|
|||||||
failedToReloadAgent: "Не удалось перезагрузить агент",
|
failedToReloadAgent: "Не удалось перезагрузить агент",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
|
unsavedTitle: "Несохранённые изменения",
|
||||||
|
unsavedMessage:
|
||||||
|
"В этой вкладке есть несохранённые изменения. При закрытии черновик будет удалён.",
|
||||||
|
closeWithoutSaving: "Закрыть без сохранения",
|
||||||
|
closePaneTitle: "Закрыть панель?",
|
||||||
|
bulkUnsaved:
|
||||||
|
"В {{count}} вкладках есть несохранённые изменения. При закрытии черновики будут удалены.",
|
||||||
close: "Закрывать",
|
close: "Закрывать",
|
||||||
cancel: "Отмена",
|
cancel: "Отмена",
|
||||||
archive: "Архив",
|
archive: "Архив",
|
||||||
@@ -1480,6 +1488,25 @@ export const ru: TranslationResources = {
|
|||||||
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
||||||
failedToLoad: "Не удалось загрузить файл",
|
failedToLoad: "Не удалось загрузить файл",
|
||||||
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
||||||
|
editor: {
|
||||||
|
fileSize: "Размер файла {{size}}",
|
||||||
|
lines: "Строк: {{count}}",
|
||||||
|
editorStatus: "Состояние редактора: {{status}}",
|
||||||
|
unsavedChanges: "Несохранённые изменения",
|
||||||
|
saving: "Сохранение...",
|
||||||
|
saveFailed: "Не удалось сохранить",
|
||||||
|
changedOnDisk: "Изменён на диске",
|
||||||
|
vimMode: "Режим Vim {{mode}}",
|
||||||
|
cursor: "Строка {{line}}, столбец {{column}}",
|
||||||
|
preview: "Просмотр",
|
||||||
|
source: "Исходник",
|
||||||
|
unavailableTitle: "Файл недоступен на диске",
|
||||||
|
conflictDescription: "Локальный буфер сохранён. Выберите версию, которую нужно оставить.",
|
||||||
|
overwrite: "Перезаписать",
|
||||||
|
reload: "Перезагрузить",
|
||||||
|
reloadTitle: "Перезагрузить с диска?",
|
||||||
|
reloadMessage: "Локальные изменения будут потеряны.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
changesLabel: "Изменения",
|
changesLabel: "Изменения",
|
||||||
@@ -1575,12 +1602,18 @@ export const ru: TranslationResources = {
|
|||||||
sections: {
|
sections: {
|
||||||
general: "Общий",
|
general: "Общий",
|
||||||
appearance: "Появление",
|
appearance: "Появление",
|
||||||
|
editor: "Редактор",
|
||||||
shortcuts: "Ярлыки",
|
shortcuts: "Ярлыки",
|
||||||
integrations: "Интеграции",
|
integrations: "Интеграции",
|
||||||
permissions: "Разрешения",
|
permissions: "Разрешения",
|
||||||
diagnostics: "Диагностика",
|
diagnostics: "Диагностика",
|
||||||
about: "О",
|
about: "О",
|
||||||
},
|
},
|
||||||
|
editor: {
|
||||||
|
title: "Редактор",
|
||||||
|
vimKeybindings: "Клавиши Vim",
|
||||||
|
vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
|
||||||
|
},
|
||||||
hostSections: {
|
hostSections: {
|
||||||
connections: "Соединения",
|
connections: "Соединения",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "正在加载...",
|
loading: "正在加载...",
|
||||||
|
modified: "未保存的更改",
|
||||||
loadingAgentTitle: "正在加载 Agent 标题",
|
loadingAgentTitle: "正在加载 Agent 标题",
|
||||||
emptyPane: "此窗格中没有标签。",
|
emptyPane: "此窗格中没有标签。",
|
||||||
fallback: {
|
fallback: {
|
||||||
@@ -532,6 +533,11 @@ export const zhCN: TranslationResources = {
|
|||||||
failedToReloadAgent: "重新加载 Agent 失败",
|
failedToReloadAgent: "重新加载 Agent 失败",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
|
unsavedTitle: "未保存的更改",
|
||||||
|
unsavedMessage: "此标签页有尚未保存的更改。关闭将丢弃草稿。",
|
||||||
|
closeWithoutSaving: "不保存并关闭",
|
||||||
|
closePaneTitle: "关闭面板?",
|
||||||
|
bulkUnsaved: "{{count}} 个标签页有未保存的更改。关闭将丢弃这些草稿。",
|
||||||
close: "关闭",
|
close: "关闭",
|
||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
archive: "归档",
|
archive: "归档",
|
||||||
@@ -1428,6 +1434,25 @@ export const zhCN: TranslationResources = {
|
|||||||
binaryPreviewUnavailable: "二进制预览不可用",
|
binaryPreviewUnavailable: "二进制预览不可用",
|
||||||
failedToLoad: "加载文件失败",
|
failedToLoad: "加载文件失败",
|
||||||
failedToLoadPreview: "加载文件预览失败",
|
failedToLoadPreview: "加载文件预览失败",
|
||||||
|
editor: {
|
||||||
|
fileSize: "文件大小 {{size}}",
|
||||||
|
lines: "{{count}} 行",
|
||||||
|
editorStatus: "编辑器状态 {{status}}",
|
||||||
|
unsavedChanges: "未保存的更改",
|
||||||
|
saving: "正在保存...",
|
||||||
|
saveFailed: "保存失败",
|
||||||
|
changedOnDisk: "磁盘上的文件已更改",
|
||||||
|
vimMode: "Vim 模式 {{mode}}",
|
||||||
|
cursor: "第 {{line}} 行,第 {{column}} 列",
|
||||||
|
preview: "预览",
|
||||||
|
source: "源代码",
|
||||||
|
unavailableTitle: "磁盘上的文件不可用",
|
||||||
|
conflictDescription: "本地内容已保留。请选择要保留的版本。",
|
||||||
|
overwrite: "覆盖",
|
||||||
|
reload: "重新加载",
|
||||||
|
reloadTitle: "从磁盘重新加载?",
|
||||||
|
reloadMessage: "本地更改将丢失。",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
changesLabel: "更改",
|
changesLabel: "更改",
|
||||||
@@ -1523,12 +1548,18 @@ export const zhCN: TranslationResources = {
|
|||||||
sections: {
|
sections: {
|
||||||
general: "通用",
|
general: "通用",
|
||||||
appearance: "外观",
|
appearance: "外观",
|
||||||
|
editor: "编辑器",
|
||||||
shortcuts: "快捷键",
|
shortcuts: "快捷键",
|
||||||
integrations: "集成",
|
integrations: "集成",
|
||||||
permissions: "权限",
|
permissions: "权限",
|
||||||
diagnostics: "诊断",
|
diagnostics: "诊断",
|
||||||
about: "关于",
|
about: "关于",
|
||||||
},
|
},
|
||||||
|
editor: {
|
||||||
|
title: "编辑器",
|
||||||
|
vimKeybindings: "Vim 键位",
|
||||||
|
vimHint: "适用于网页和桌面端的源文件。",
|
||||||
|
},
|
||||||
hostSections: {
|
hostSections: {
|
||||||
connections: "连接",
|
connections: "连接",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ function useAgentPanelDescriptor(
|
|||||||
return {
|
return {
|
||||||
label: label ?? "",
|
label: label ?? "",
|
||||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||||
|
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
|
||||||
titleState: label ? "ready" : "loading",
|
titleState: label ? "ready" : "loading",
|
||||||
icon,
|
icon,
|
||||||
statusBucket: descriptorState.status
|
statusBucket: descriptorState.status
|
||||||
|
|||||||
@@ -42,10 +42,12 @@ function useBrowserPanelDescriptor(target: {
|
|||||||
const browser = useBrowserStore((state) => state.browsersById[target.browserId] ?? null);
|
const browser = useBrowserStore((state) => state.browsersById[target.browserId] ?? null);
|
||||||
const url = browser?.url ?? "https://example.com";
|
const url = browser?.url ?? "https://example.com";
|
||||||
const icon = createBrowserTabIcon(browser?.faviconUrl ?? null);
|
const icon = createBrowserTabIcon(browser?.faviconUrl ?? null);
|
||||||
|
const label = getBrowserLabel({ title: browser?.title ?? "", url });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: getBrowserLabel({ title: browser?.title ?? "", url }),
|
label,
|
||||||
subtitle: url,
|
subtitle: url,
|
||||||
|
tooltip: url || label,
|
||||||
titleState: "ready",
|
titleState: "ready",
|
||||||
icon,
|
icon,
|
||||||
statusBucket: browser?.isLoading ? "running" : null,
|
statusBucket: browser?.isLoading ? "running" : null,
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ function useCommitDiffPanelDescriptor(
|
|||||||
return {
|
return {
|
||||||
label: target.sha.slice(0, 7),
|
label: target.sha.slice(0, 7),
|
||||||
subtitle: t("panels.diff.commitSubtitle"),
|
subtitle: t("panels.diff.commitSubtitle"),
|
||||||
|
tooltip: target.sha,
|
||||||
titleState: "ready",
|
titleState: "ready",
|
||||||
icon: ThemedGitCommitHorizontal,
|
icon: ThemedGitCommitHorizontal,
|
||||||
statusBucket: null,
|
statusBucket: null,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function buildDraftPanelDescriptor(input: {
|
|||||||
return {
|
return {
|
||||||
label: creatingLabel,
|
label: creatingLabel,
|
||||||
subtitle: i18n.t("panels.draft.creatingAgent"),
|
subtitle: i18n.t("panels.draft.creatingAgent"),
|
||||||
|
tooltip: creatingLabel,
|
||||||
titleState: "ready",
|
titleState: "ready",
|
||||||
icon,
|
icon,
|
||||||
statusBucket: "running",
|
statusBucket: "running",
|
||||||
@@ -23,6 +24,7 @@ export function buildDraftPanelDescriptor(input: {
|
|||||||
return {
|
return {
|
||||||
label: newAgentLabel,
|
label: newAgentLabel,
|
||||||
subtitle: newAgentLabel,
|
subtitle: newAgentLabel,
|
||||||
|
tooltip: newAgentLabel,
|
||||||
titleState: "ready",
|
titleState: "ready",
|
||||||
icon,
|
icon,
|
||||||
statusBucket: null,
|
statusBucket: null,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { FileText } from "lucide-react-native";
|
import { useMemo } from "react";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FilePane } from "@/components/file-pane";
|
import { FilePane } from "@/file-pane/pane";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||||
|
import { createMaterialFileIcon } from "@/components/material-file-icon";
|
||||||
|
|
||||||
const CENTERED_PADDED_STYLE = {
|
const CENTERED_PADDED_STYLE = {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -16,11 +17,13 @@ const CENTERED_PADDED_STYLE = {
|
|||||||
|
|
||||||
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
||||||
const fileName = target.path.split("/").findLast(Boolean) ?? target.path;
|
const fileName = target.path.split("/").findLast(Boolean) ?? target.path;
|
||||||
|
const icon = useMemo(() => createMaterialFileIcon(fileName), [fileName]);
|
||||||
return {
|
return {
|
||||||
label: fileName,
|
label: fileName,
|
||||||
subtitle: target.path,
|
subtitle: target.path,
|
||||||
|
tooltip: target.path,
|
||||||
titleState: "ready" as const,
|
titleState: "ready" as const,
|
||||||
icon: FileText,
|
icon,
|
||||||
statusBucket: null,
|
statusBucket: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
35
packages/app/src/panels/panel-instance-attributes.test.ts
Normal file
35
packages/app/src/panels/panel-instance-attributes.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
getPanelInstanceAttributes,
|
||||||
|
setPanelInstanceAttributes,
|
||||||
|
subscribePanelInstanceAttributes,
|
||||||
|
} from "./panel-instance-attributes";
|
||||||
|
|
||||||
|
describe("panel instance attributes", () => {
|
||||||
|
test("keeps runtime attributes isolated by workspace and tab", () => {
|
||||||
|
const first = { serverId: "server", workspaceId: "one", tabId: "tab" };
|
||||||
|
const second = { serverId: "server", workspaceId: "two", tabId: "tab" };
|
||||||
|
|
||||||
|
setPanelInstanceAttributes(first, { modified: true });
|
||||||
|
|
||||||
|
expect(getPanelInstanceAttributes(first)).toEqual({ modified: true });
|
||||||
|
expect(getPanelInstanceAttributes(second)).toEqual({ modified: false });
|
||||||
|
|
||||||
|
setPanelInstanceAttributes(first, { modified: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notifies subscribers only when attributes change", () => {
|
||||||
|
const identity = { serverId: "server", workspaceId: "workspace", tabId: "observed" };
|
||||||
|
let notifications = 0;
|
||||||
|
const unsubscribe = subscribePanelInstanceAttributes(identity, () => {
|
||||||
|
notifications += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
setPanelInstanceAttributes(identity, { modified: true });
|
||||||
|
setPanelInstanceAttributes(identity, { modified: true });
|
||||||
|
setPanelInstanceAttributes(identity, { modified: false });
|
||||||
|
|
||||||
|
expect(notifications).toBe(2);
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
118
packages/app/src/panels/panel-instance-attributes.ts
Normal file
118
packages/app/src/panels/panel-instance-attributes.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||||
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
|
|
||||||
|
export interface PanelInstanceIdentity {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
tabId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelInstanceAttributes {
|
||||||
|
modified: boolean;
|
||||||
|
suspendPendingSave?: () => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ATTRIBUTES: PanelInstanceAttributes = { modified: false };
|
||||||
|
const attributesByPanel = new Map<string, PanelInstanceAttributes>();
|
||||||
|
const listenersByPanel = new Map<string, Set<() => void>>();
|
||||||
|
const allListeners = new Set<() => void>();
|
||||||
|
let attributesRevision = 0;
|
||||||
|
|
||||||
|
export function buildPanelInstanceKey(identity: PanelInstanceIdentity): string {
|
||||||
|
return `${identity.serverId}:${identity.workspaceId}:${identity.tabId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPanelInstanceAttributes(
|
||||||
|
identity: PanelInstanceIdentity,
|
||||||
|
): PanelInstanceAttributes {
|
||||||
|
return attributesByPanel.get(buildPanelInstanceKey(identity)) ?? DEFAULT_ATTRIBUTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPanelInstanceAttributes(
|
||||||
|
identity: PanelInstanceIdentity,
|
||||||
|
attributes: PanelInstanceAttributes,
|
||||||
|
): void {
|
||||||
|
const key = buildPanelInstanceKey(identity);
|
||||||
|
const previous = attributesByPanel.get(key) ?? DEFAULT_ATTRIBUTES;
|
||||||
|
if (
|
||||||
|
previous.modified === attributes.modified &&
|
||||||
|
previous.suspendPendingSave === attributes.suspendPendingSave
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attributes.modified) attributesByPanel.set(key, attributes);
|
||||||
|
else attributesByPanel.delete(key);
|
||||||
|
attributesRevision += 1;
|
||||||
|
for (const listener of listenersByPanel.get(key) ?? []) listener();
|
||||||
|
for (const listener of allListeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModifiedPanelTabIds(input: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
tabIds: string[];
|
||||||
|
}): Set<string> {
|
||||||
|
const revision = useSyncExternalStore(
|
||||||
|
useCallback((listener: () => void) => {
|
||||||
|
allListeners.add(listener);
|
||||||
|
return () => allListeners.delete(listener);
|
||||||
|
}, []),
|
||||||
|
() => attributesRevision,
|
||||||
|
() => attributesRevision,
|
||||||
|
);
|
||||||
|
return useMemo(() => {
|
||||||
|
void revision;
|
||||||
|
return new Set(
|
||||||
|
input.tabIds.filter(
|
||||||
|
(tabId) =>
|
||||||
|
getPanelInstanceAttributes({
|
||||||
|
serverId: input.serverId,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
tabId,
|
||||||
|
}).modified,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [input.serverId, input.tabIds, input.workspaceId, revision]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribePanelInstanceAttributes(
|
||||||
|
identity: PanelInstanceIdentity,
|
||||||
|
listener: () => void,
|
||||||
|
): () => void {
|
||||||
|
const key = buildPanelInstanceKey(identity);
|
||||||
|
const listeners = listenersByPanel.get(key) ?? new Set<() => void>();
|
||||||
|
listeners.add(listener);
|
||||||
|
listenersByPanel.set(key, listeners);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
if (listeners.size === 0) listenersByPanel.delete(key);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePanelInstanceAttributes({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
tabId,
|
||||||
|
}: PanelInstanceIdentity): PanelInstanceAttributes {
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(listener: () => void) =>
|
||||||
|
subscribePanelInstanceAttributes({ serverId, workspaceId, tabId }, listener),
|
||||||
|
[serverId, tabId, workspaceId],
|
||||||
|
);
|
||||||
|
const getSnapshot = useCallback(
|
||||||
|
() => getPanelInstanceAttributes({ serverId, workspaceId, tabId }),
|
||||||
|
[serverId, tabId, workspaceId],
|
||||||
|
);
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePublishPanelInstanceAttributes(attributes: PanelInstanceAttributes): void {
|
||||||
|
const { serverId, workspaceId, tabId } = usePaneContext();
|
||||||
|
const modified = attributes.modified;
|
||||||
|
const suspendPendingSave = attributes.suspendPendingSave;
|
||||||
|
useEffect(() => {
|
||||||
|
const identity = { serverId, workspaceId, tabId };
|
||||||
|
setPanelInstanceAttributes(identity, { modified, suspendPendingSave });
|
||||||
|
return () => setPanelInstanceAttributes(identity, DEFAULT_ATTRIBUTES);
|
||||||
|
}, [modified, serverId, suspendPendingSave, tabId, workspaceId]);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export interface PanelIconProps {
|
|||||||
export interface PanelDescriptor {
|
export interface PanelDescriptor {
|
||||||
label: string;
|
label: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
tooltip: string;
|
||||||
titleState: "ready" | "loading";
|
titleState: "ready" | "loading";
|
||||||
icon: ComponentType<PanelIconProps>;
|
icon: ComponentType<PanelIconProps>;
|
||||||
statusBucket: SidebarStateBucket | null;
|
statusBucket: SidebarStateBucket | null;
|
||||||
@@ -18,6 +19,7 @@ export interface PanelDescriptor {
|
|||||||
export interface PanelDescriptorContext {
|
export interface PanelDescriptorContext {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
tabId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PanelRegistration<
|
export interface PanelRegistration<
|
||||||
@@ -29,10 +31,6 @@ export interface PanelRegistration<
|
|||||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||||
context: PanelDescriptorContext,
|
context: PanelDescriptorContext,
|
||||||
): PanelDescriptor;
|
): PanelDescriptor;
|
||||||
confirmClose?(
|
|
||||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
|
||||||
context: PanelDescriptorContext,
|
|
||||||
): Promise<boolean>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelRegistry = new Map<WorkspaceTabTarget["kind"], PanelRegistration>();
|
const panelRegistry = new Map<WorkspaceTabTarget["kind"], PanelRegistration>();
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function useProviderSubagentDescriptor(
|
|||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
subtitle: `${formatProviderLabel(provider)} subagent`,
|
subtitle: `${formatProviderLabel(provider)} subagent`,
|
||||||
|
tooltip: label,
|
||||||
titleState: descriptor ? "ready" : "loading",
|
titleState: descriptor ? "ready" : "loading",
|
||||||
icon: getProviderIcon(provider),
|
icon: getProviderIcon(provider),
|
||||||
statusBucket: descriptor
|
statusBucket: descriptor
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function useSetupPanelDescriptor(
|
|||||||
return {
|
return {
|
||||||
label: t("workspace.setup.descriptor.label"),
|
label: t("workspace.setup.descriptor.label"),
|
||||||
subtitle: t("workspace.setup.descriptor.completed"),
|
subtitle: t("workspace.setup.descriptor.completed"),
|
||||||
|
tooltip: t("workspace.setup.descriptor.completed"),
|
||||||
titleState: "ready",
|
titleState: "ready",
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
statusBucket: null,
|
statusBucket: null,
|
||||||
@@ -47,6 +48,7 @@ function useSetupPanelDescriptor(
|
|||||||
return {
|
return {
|
||||||
label: t("workspace.setup.descriptor.label"),
|
label: t("workspace.setup.descriptor.label"),
|
||||||
subtitle: t("workspace.setup.descriptor.failed"),
|
subtitle: t("workspace.setup.descriptor.failed"),
|
||||||
|
tooltip: t("workspace.setup.descriptor.failed"),
|
||||||
titleState: "ready",
|
titleState: "ready",
|
||||||
icon: CircleAlert,
|
icon: CircleAlert,
|
||||||
statusBucket: null,
|
statusBucket: null,
|
||||||
@@ -56,6 +58,7 @@ function useSetupPanelDescriptor(
|
|||||||
return {
|
return {
|
||||||
label: t("workspace.setup.descriptor.label"),
|
label: t("workspace.setup.descriptor.label"),
|
||||||
subtitle: t("workspace.setup.descriptor.workspace"),
|
subtitle: t("workspace.setup.descriptor.workspace"),
|
||||||
|
tooltip: t("workspace.setup.descriptor.workspace"),
|
||||||
titleState: "ready",
|
titleState: "ready",
|
||||||
icon: SquareTerminal,
|
icon: SquareTerminal,
|
||||||
statusBucket: snapshot?.status === "running" ? "running" : null,
|
statusBucket: snapshot?.status === "running" ? "running" : null,
|
||||||
|
|||||||
@@ -62,12 +62,14 @@ function useTerminalPanelDescriptor(
|
|||||||
);
|
);
|
||||||
const terminal =
|
const terminal =
|
||||||
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
|
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
|
||||||
|
const label =
|
||||||
|
trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ??
|
||||||
|
t("workspace.tabs.fallback.terminal");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label:
|
label,
|
||||||
trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ??
|
|
||||||
t("workspace.tabs.fallback.terminal"),
|
|
||||||
subtitle: t("workspace.tabs.fallback.terminal"),
|
subtitle: t("workspace.tabs.fallback.terminal"),
|
||||||
|
tooltip: label,
|
||||||
titleState: "ready",
|
titleState: "ready",
|
||||||
icon: Terminal,
|
icon: Terminal,
|
||||||
statusBucket: deriveTerminalActivityStatusBucket(terminal?.activity),
|
statusBucket: deriveTerminalActivityStatusBucket(terminal?.activity),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
FolderGit2,
|
FolderGit2,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
|
Code2,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||||
@@ -65,6 +66,7 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
|||||||
import { AddHostModal } from "@/components/add-host-modal";
|
import { AddHostModal } from "@/components/add-host-modal";
|
||||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||||
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
|
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
|
||||||
|
import { EditorSection } from "@/screens/settings/editor-section";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CommunityLinks } from "@/components/community-links";
|
import { CommunityLinks } from "@/components/community-links";
|
||||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
@@ -135,6 +137,7 @@ interface SidebarSectionItem {
|
|||||||
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
||||||
{ id: "general", labelKey: "settings.sections.general", icon: Settings },
|
{ id: "general", labelKey: "settings.sections.general", icon: Settings },
|
||||||
{ id: "appearance", labelKey: "settings.sections.appearance", icon: Palette },
|
{ id: "appearance", labelKey: "settings.sections.appearance", icon: Palette },
|
||||||
|
{ id: "editor", labelKey: "settings.sections.editor", icon: Code2 },
|
||||||
{ id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true },
|
{ id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true },
|
||||||
{
|
{
|
||||||
id: "integrations",
|
id: "integrations",
|
||||||
@@ -1403,6 +1406,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
);
|
);
|
||||||
case "appearance":
|
case "appearance":
|
||||||
return <AppearanceSection />;
|
return <AppearanceSection />;
|
||||||
|
case "editor":
|
||||||
|
return <EditorSection />;
|
||||||
case "shortcuts":
|
case "shortcuts":
|
||||||
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
|
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
|
||||||
case "integrations":
|
case "integrations":
|
||||||
|
|||||||
33
packages/app/src/screens/settings/editor-section.tsx
Normal file
33
packages/app/src/screens/settings/editor-section.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Switch, Text, View } from "react-native";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
|
import { SettingsSection } from "./settings-section";
|
||||||
|
import { settingsStyles } from "@/styles/settings";
|
||||||
|
|
||||||
|
export function EditorSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { settings, updateSettings } = useAppSettings();
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(vimKeybindings: boolean) => void updateSettings({ vimKeybindings }),
|
||||||
|
[updateSettings],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<SettingsSection title={t("settings.editor.title")}>
|
||||||
|
<View style={settingsStyles.card}>
|
||||||
|
<View style={settingsStyles.row}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>{t("settings.editor.vimKeybindings")}</Text>
|
||||||
|
<Text style={settingsStyles.rowHint}>{t("settings.editor.vimHint")}</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.vimKeybindings}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
accessibilityLabel={t("settings.editor.vimKeybindings")}
|
||||||
|
testID="vim-keybindings-toggle"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,4 +49,23 @@ describe("useMountedTabSet", () => {
|
|||||||
rerender({ activeTabId: "third" });
|
rerender({ activeTabId: "third" });
|
||||||
expect(mountedIds(result)).toEqual(["third", "second"]);
|
expect(mountedIds(result)).toEqual(["third", "second"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps retained panels mounted beyond the normal cap", () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ activeTabId }) =>
|
||||||
|
useMountedTabSet({
|
||||||
|
activeTabId,
|
||||||
|
allTabIds: ["modified", "second", "third", "fourth"],
|
||||||
|
retainedTabIds: new Set(["modified"]),
|
||||||
|
cap: 2,
|
||||||
|
}),
|
||||||
|
{ initialProps: { activeTabId: "modified" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender({ activeTabId: "second" });
|
||||||
|
rerender({ activeTabId: "third" });
|
||||||
|
rerender({ activeTabId: "fourth" });
|
||||||
|
|
||||||
|
expect(mountedIds(result)).toEqual(["fourth", "modified"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ interface UseMountedTabSetInput {
|
|||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
allTabIds: string[];
|
allTabIds: string[];
|
||||||
cap: number;
|
cap: number;
|
||||||
|
retainedTabIds?: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseMountedTabSetResult {
|
interface UseMountedTabSetResult {
|
||||||
@@ -15,6 +16,7 @@ interface DeriveMountedTabLruInput {
|
|||||||
availableTabIds: Set<string>;
|
availableTabIds: Set<string>;
|
||||||
cap: number;
|
cap: number;
|
||||||
previousLru: string[];
|
previousLru: string[];
|
||||||
|
retainedTabIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInitialMountedTabLru(input: UseMountedTabSetInput): string[] {
|
function createInitialMountedTabLru(input: UseMountedTabSetInput): string[] {
|
||||||
@@ -25,7 +27,7 @@ function createInitialMountedTabLru(input: UseMountedTabSetInput): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deriveMountedTabLru(input: DeriveMountedTabLruInput): string[] {
|
function deriveMountedTabLru(input: DeriveMountedTabLruInput): string[] {
|
||||||
const { activeTabId, availableTabIds, cap, previousLru } = input;
|
const { activeTabId, availableTabIds, cap, previousLru, retainedTabIds } = input;
|
||||||
const maxSize = Math.max(1, cap);
|
const maxSize = Math.max(1, cap);
|
||||||
|
|
||||||
const next: string[] = [];
|
const next: string[] = [];
|
||||||
@@ -33,6 +35,10 @@ function deriveMountedTabLru(input: DeriveMountedTabLruInput): string[] {
|
|||||||
next.push(activeTabId);
|
next.push(activeTabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const tabId of retainedTabIds) {
|
||||||
|
if (tabId !== activeTabId && availableTabIds.has(tabId)) next.push(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
for (const tabId of previousLru) {
|
for (const tabId of previousLru) {
|
||||||
if (next.length >= maxSize) break;
|
if (next.length >= maxSize) break;
|
||||||
if (tabId !== activeTabId && availableTabIds.has(tabId)) {
|
if (tabId !== activeTabId && availableTabIds.has(tabId)) {
|
||||||
@@ -57,8 +63,9 @@ export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSet
|
|||||||
availableTabIds,
|
availableTabIds,
|
||||||
cap,
|
cap,
|
||||||
previousLru: committedLruRef.current,
|
previousLru: committedLruRef.current,
|
||||||
|
retainedTabIds: input.retainedTabIds ?? new Set(),
|
||||||
}),
|
}),
|
||||||
[activeTabId, availableTabIds, cap],
|
[activeTabId, availableTabIds, cap, input.retainedTabIds],
|
||||||
);
|
);
|
||||||
const mountedTabIds = useMemo(() => new Set<string>(mountedTabLru), [mountedTabLru]);
|
const mountedTabIds = useMemo(() => new Set<string>(mountedTabLru), [mountedTabLru]);
|
||||||
|
|
||||||
|
|||||||
@@ -553,12 +553,14 @@ function TabChip({
|
|||||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
dragHandleProps: DraggableListDragHandleProps | undefined;
|
dragHandleProps: DraggableListDragHandleProps | undefined;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
|
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
|
||||||
const middleClickRef = useMiddleClickClose(
|
const middleClickRef = useMiddleClickClose(
|
||||||
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
|
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
|
||||||
);
|
);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const isHighlighted = isActive || hovered || isCloseHovered;
|
const isHighlighted = isActive || hovered || isCloseHovered;
|
||||||
|
const showTrailingAffordance = showCloseButton || presentation.modified;
|
||||||
const closeButtonDragBlockers = isWeb
|
const closeButtonDragBlockers = isWeb
|
||||||
? ({
|
? ({
|
||||||
onPointerDown: (event: { stopPropagation?: () => void }) => {
|
onPointerDown: (event: { stopPropagation?: () => void }) => {
|
||||||
@@ -630,16 +632,19 @@ function TabChip({
|
|||||||
[isFocused],
|
[isFocused],
|
||||||
);
|
);
|
||||||
const tabLabelSkeletonStyle = useMemo(
|
const tabLabelSkeletonStyle = useMemo(
|
||||||
() => [styles.tabLabelSkeleton, showCloseButton && styles.tabLabelSkeletonWithCloseButton],
|
() => [
|
||||||
[showCloseButton],
|
styles.tabLabelSkeleton,
|
||||||
|
showTrailingAffordance && styles.tabLabelSkeletonWithCloseButton,
|
||||||
|
],
|
||||||
|
[showTrailingAffordance],
|
||||||
);
|
);
|
||||||
const tabLabelStyle = useMemo(
|
const tabLabelStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
styles.tabLabel,
|
styles.tabLabel,
|
||||||
isHighlighted && styles.tabLabelActive,
|
isHighlighted && styles.tabLabelActive,
|
||||||
showCloseButton && styles.tabLabelWithCloseButton,
|
showTrailingAffordance && styles.tabLabelWithCloseButton,
|
||||||
],
|
],
|
||||||
[isHighlighted, showCloseButton],
|
[isHighlighted, showTrailingAffordance],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -672,7 +677,7 @@ function TabChip({
|
|||||||
tabLabelStyle={tabLabelStyle}
|
tabLabelStyle={tabLabelStyle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showCloseButton ? (
|
{showTrailingAffordance ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
{...(closeButtonDragBlockers as object | undefined)}
|
{...(closeButtonDragBlockers as object | undefined)}
|
||||||
testID={closeButtonTestId}
|
testID={closeButtonTestId}
|
||||||
@@ -683,28 +688,43 @@ function TabChip({
|
|||||||
onPress={handleCloseButtonPress}
|
onPress={handleCloseButtonPress}
|
||||||
style={closeButtonStyle}
|
style={closeButtonStyle}
|
||||||
>
|
>
|
||||||
{({ hovered: closeHovered, pressed }) =>
|
{({ hovered: closeHovered, pressed }) => {
|
||||||
isClosingTab ? (
|
const highlighted = closeHovered || pressed;
|
||||||
<ThemedActivityIndicator
|
if (isClosingTab) {
|
||||||
size={12}
|
return (
|
||||||
uniProps={
|
<ThemedActivityIndicator
|
||||||
closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
|
size={12}
|
||||||
}
|
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (highlighted || !presentation.modified) {
|
||||||
|
return (
|
||||||
|
<ThemedX
|
||||||
|
size={12}
|
||||||
|
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={styles.tabModifiedDot}
|
||||||
|
accessibilityLabel={t("workspace.tabs.modified")}
|
||||||
|
testID={`workspace-tab-modified-${buildDeterministicWorkspaceTabId(tab.target)}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
);
|
||||||
<ThemedX
|
}}
|
||||||
size={12}
|
|
||||||
uniProps={
|
|
||||||
closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="center" offset={8}>
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
align="center"
|
||||||
|
offset={8}
|
||||||
|
maxWidth={720}
|
||||||
|
testID={`workspace-tab-tooltip-${buildDeterministicWorkspaceTabId(tab.target)}`}
|
||||||
|
>
|
||||||
{tab.target.kind === "agent" ? (
|
{tab.target.kind === "agent" ? (
|
||||||
<View style={styles.tooltipAgentRow}>
|
<View style={styles.tooltipAgentRow}>
|
||||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||||
@@ -1161,7 +1181,7 @@ function ResolvedDesktopTabChip({
|
|||||||
const tooltipLabel =
|
const tooltipLabel =
|
||||||
presentation.titleState === "loading"
|
presentation.titleState === "loading"
|
||||||
? t("workspace.tabs.loadingAgentTitle")
|
? t("workspace.tabs.loadingAgentTitle")
|
||||||
: presentation.label;
|
: presentation.tooltip;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.tabSlot}>
|
<View style={styles.tabSlot}>
|
||||||
@@ -1331,6 +1351,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
tabCloseButtonActive: {
|
tabCloseButtonActive: {
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface3,
|
||||||
},
|
},
|
||||||
|
tabModifiedDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
newTabActionButton: {
|
newTabActionButton: {
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 22,
|
height: 22,
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ import {
|
|||||||
closeBulkWorkspaceTabs,
|
closeBulkWorkspaceTabs,
|
||||||
} from "@/screens/workspace/workspace-bulk-close";
|
} from "@/screens/workspace/workspace-bulk-close";
|
||||||
import { resolveCloseAgentTabPolicy } from "@/subagents";
|
import { resolveCloseAgentTabPolicy } from "@/subagents";
|
||||||
|
import {
|
||||||
|
getPanelInstanceAttributes,
|
||||||
|
useModifiedPanelTabIds,
|
||||||
|
} from "@/panels/panel-instance-attributes";
|
||||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||||
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||||
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
||||||
@@ -2661,11 +2665,8 @@ function WorkspaceScreenContent({
|
|||||||
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t],
|
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseDraftOrFileTab = useCallback(
|
const handleClosePassiveTab = useCallback(
|
||||||
function handleCloseDraftOrFileTab(input: {
|
function handleClosePassiveTab(input: { tabId: string; target?: WorkspaceTabTarget | null }) {
|
||||||
tabId: string;
|
|
||||||
target?: WorkspaceTabTarget | null;
|
|
||||||
}) {
|
|
||||||
setHoveredCloseTabKey((current) => (current === input.tabId ? null : current));
|
setHoveredCloseTabKey((current) => (current === input.tabId ? null : current));
|
||||||
if (persistenceKey) {
|
if (persistenceKey) {
|
||||||
closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target });
|
closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target });
|
||||||
@@ -2674,12 +2675,37 @@ function WorkspaceScreenContent({
|
|||||||
[closeWorkspaceTabWithCleanup, persistenceKey],
|
[closeWorkspaceTabWithCleanup, persistenceKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const confirmDiscardModifiedTab = useCallback(
|
||||||
|
async (tabId: string): Promise<boolean> => {
|
||||||
|
const attributes = getPanelInstanceAttributes({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
tabId,
|
||||||
|
});
|
||||||
|
if (!attributes.modified) return true;
|
||||||
|
const resumePendingSave = attributes.suspendPendingSave?.();
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: t("workspace.tabs.confirmations.unsavedTitle"),
|
||||||
|
message: t("workspace.tabs.confirmations.unsavedMessage"),
|
||||||
|
confirmLabel: t("workspace.tabs.confirmations.closeWithoutSaving"),
|
||||||
|
cancelLabel: t("workspace.tabs.confirmations.cancel"),
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
if (!confirmed) resumePendingSave?.();
|
||||||
|
return confirmed;
|
||||||
|
},
|
||||||
|
[normalizedServerId, normalizedWorkspaceId, t],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCloseTabById = useCallback(
|
const handleCloseTabById = useCallback(
|
||||||
async (tabId: string) => {
|
async (tabId: string) => {
|
||||||
const tab = allTabDescriptorsById.get(tabId);
|
const tab = allTabDescriptorsById.get(tabId);
|
||||||
if (!tab) {
|
if (!tab) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!(await confirmDiscardModifiedTab(tabId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (tab.target.kind === "terminal") {
|
if (tab.target.kind === "terminal") {
|
||||||
await handleCloseTerminalTab({ tabId, terminalId: tab.target.terminalId });
|
await handleCloseTerminalTab({ tabId, terminalId: tab.target.terminalId });
|
||||||
return;
|
return;
|
||||||
@@ -2688,9 +2714,15 @@ function WorkspaceScreenContent({
|
|||||||
await handleCloseAgentTab({ tabId, agentId: tab.target.agentId });
|
await handleCloseAgentTab({ tabId, agentId: tab.target.agentId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleCloseDraftOrFileTab({ tabId, target: tab.target });
|
handleClosePassiveTab({ tabId, target: tab.target });
|
||||||
},
|
},
|
||||||
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
|
[
|
||||||
|
allTabDescriptorsById,
|
||||||
|
confirmDiscardModifiedTab,
|
||||||
|
handleCloseAgentTab,
|
||||||
|
handleClosePassiveTab,
|
||||||
|
handleCloseTerminalTab,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyAgentId = useCallback(
|
const handleCopyAgentId = useCallback(
|
||||||
@@ -2834,9 +2866,21 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groups = classifyBulkClosableTabs(tabsToClose);
|
const groups = classifyBulkClosableTabs(tabsToClose);
|
||||||
|
const modifiedCount = tabsToClose.filter(
|
||||||
|
(tab) =>
|
||||||
|
getPanelInstanceAttributes({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
tabId: tab.tabId,
|
||||||
|
}).modified,
|
||||||
|
).length;
|
||||||
|
const bulkMessage = buildBulkCloseConfirmationMessage(groups, bulkCloseConfirmationLabels);
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title,
|
title,
|
||||||
message: buildBulkCloseConfirmationMessage(groups, bulkCloseConfirmationLabels),
|
message:
|
||||||
|
modifiedCount > 0
|
||||||
|
? `${bulkMessage}\n\n${t("workspace.tabs.confirmations.bulkUnsaved", { count: modifiedCount })}`
|
||||||
|
: bulkMessage,
|
||||||
confirmLabel: t("workspace.tabs.confirmations.close"),
|
confirmLabel: t("workspace.tabs.confirmations.close"),
|
||||||
cancelLabel: t("workspace.tabs.confirmations.cancel"),
|
cancelLabel: t("workspace.tabs.confirmations.cancel"),
|
||||||
destructive: true,
|
destructive: true,
|
||||||
@@ -2869,6 +2913,8 @@ function WorkspaceScreenContent({
|
|||||||
client,
|
client,
|
||||||
closeTab,
|
closeTab,
|
||||||
closeWorkspaceTabWithCleanup,
|
closeWorkspaceTabWithCleanup,
|
||||||
|
normalizedServerId,
|
||||||
|
normalizedWorkspaceId,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
t,
|
t,
|
||||||
],
|
],
|
||||||
@@ -3055,12 +3101,15 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.id === "workspace.pane.close") {
|
if (action.id === "workspace.pane.close") {
|
||||||
for (const tabId of focusedPane.tabIds) {
|
const tabsToClose = focusedPane.tabIds.flatMap((tabId) => {
|
||||||
closeWorkspaceTabWithCleanup({
|
const tab = allTabDescriptorsById.get(tabId);
|
||||||
tabId,
|
return tab ? [tab] : [];
|
||||||
target: allTabDescriptorsById.get(tabId)?.target ?? null,
|
});
|
||||||
});
|
void handleBulkCloseTabs({
|
||||||
}
|
tabsToClose,
|
||||||
|
title: t("workspace.tabs.confirmations.closePaneTitle"),
|
||||||
|
logLabel: "from pane close",
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3068,14 +3117,15 @@ function WorkspaceScreenContent({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
allTabDescriptorsById,
|
allTabDescriptorsById,
|
||||||
closeWorkspaceTabWithCleanup,
|
|
||||||
focusWorkspacePane,
|
focusWorkspacePane,
|
||||||
|
handleBulkCloseTabs,
|
||||||
handleCreateDraftSplit,
|
handleCreateDraftSplit,
|
||||||
moveWorkspaceTabToPane,
|
moveWorkspaceTabToPane,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
focusedPaneTabState.activeTabId,
|
focusedPaneTabState.activeTabId,
|
||||||
focusedPaneTabState.pane,
|
focusedPaneTabState.pane,
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
|
t,
|
||||||
workspaceLayout,
|
workspaceLayout,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -3210,10 +3260,16 @@ function WorkspaceScreenContent({
|
|||||||
[focusedPaneTabState.pane],
|
[focusedPaneTabState.pane],
|
||||||
);
|
);
|
||||||
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
|
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
|
||||||
|
const modifiedFocusedPaneTabIds = useModifiedPanelTabIds({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
tabIds: focusedPaneTabIds,
|
||||||
|
});
|
||||||
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
|
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
|
||||||
const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({
|
const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({
|
||||||
activeTabId,
|
activeTabId,
|
||||||
allTabIds: focusedPaneTabIds,
|
allTabIds: focusedPaneTabIds,
|
||||||
|
retainedTabIds: modifiedFocusedPaneTabIds,
|
||||||
cap: 3,
|
cap: 3,
|
||||||
});
|
});
|
||||||
const mountedFocusedPaneTabIds = useMemo(
|
const mountedFocusedPaneTabIds = useMemo(
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
|||||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import { usePanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||||
|
|
||||||
export interface WorkspaceTabPresentation {
|
export interface WorkspaceTabPresentation {
|
||||||
key: string;
|
key: string;
|
||||||
kind: WorkspaceTabDescriptor["kind"];
|
kind: WorkspaceTabDescriptor["kind"];
|
||||||
label: string;
|
label: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
tooltip: string;
|
||||||
|
modified: boolean;
|
||||||
titleState: "ready" | "loading";
|
titleState: "ready" | "loading";
|
||||||
icon: React.ComponentType<{ size: number; color: string }>;
|
icon: React.ComponentType<{ size: number; color: string }>;
|
||||||
statusBucket: SidebarStateBucket | null;
|
statusBucket: SidebarStateBucket | null;
|
||||||
@@ -72,7 +75,9 @@ function WorkspaceTabPresentationResolverInner({
|
|||||||
const descriptor = registration.useDescriptor(tab.target as never, {
|
const descriptor = registration.useDescriptor(tab.target as never, {
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
tabId: tab.tabId,
|
||||||
});
|
});
|
||||||
|
const attributes = usePanelInstanceAttributes({ serverId, workspaceId, tabId: tab.tabId });
|
||||||
|
|
||||||
const presentation = useMemo(
|
const presentation = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -80,6 +85,8 @@ function WorkspaceTabPresentationResolverInner({
|
|||||||
kind: tab.kind,
|
kind: tab.kind,
|
||||||
label: descriptor.label,
|
label: descriptor.label,
|
||||||
subtitle: descriptor.subtitle,
|
subtitle: descriptor.subtitle,
|
||||||
|
tooltip: descriptor.tooltip,
|
||||||
|
modified: attributes.modified,
|
||||||
titleState: descriptor.titleState,
|
titleState: descriptor.titleState,
|
||||||
icon: descriptor.icon,
|
icon: descriptor.icon,
|
||||||
statusBucket: descriptor.statusBucket,
|
statusBucket: descriptor.statusBucket,
|
||||||
@@ -87,11 +94,13 @@ function WorkspaceTabPresentationResolverInner({
|
|||||||
[
|
[
|
||||||
descriptor.icon,
|
descriptor.icon,
|
||||||
descriptor.label,
|
descriptor.label,
|
||||||
|
descriptor.tooltip,
|
||||||
descriptor.statusBucket,
|
descriptor.statusBucket,
|
||||||
descriptor.subtitle,
|
descriptor.subtitle,
|
||||||
descriptor.titleState,
|
descriptor.titleState,
|
||||||
tab.key,
|
tab.key,
|
||||||
tab.kind,
|
tab.kind,
|
||||||
|
attributes.modified,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -208,6 +217,9 @@ export function WorkspaceTabOptionRow({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
{presentation.modified ? (
|
||||||
|
<View style={styles.optionModifiedDot} accessibilityLabel={t("workspace.tabs.modified")} />
|
||||||
|
) : null}
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<View style={styles.optionTrailingSlot}>
|
<View style={styles.optionTrailingSlot}>
|
||||||
<ThemedCheckIcon size={16} uniProps={mutedColorMapping} />
|
<ThemedCheckIcon size={16} uniProps={mutedColorMapping} />
|
||||||
@@ -303,6 +315,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
|
optionModifiedDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
optionTrailingAccessorySlot: {
|
optionTrailingAccessorySlot: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -41,8 +41,11 @@ export interface SubagentsTrackProps {
|
|||||||
const SUBAGENTS_LIST_MAX_HEIGHT = 200;
|
const SUBAGENTS_LIST_MAX_HEIGHT = 200;
|
||||||
|
|
||||||
function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
|
function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
|
||||||
|
const data = buildSubagentRowPresentationData(row);
|
||||||
return {
|
return {
|
||||||
...buildSubagentRowPresentationData(row),
|
...data,
|
||||||
|
tooltip: data.label,
|
||||||
|
modified: false,
|
||||||
icon: getProviderIcon(row.provider),
|
icon: getProviderIcon(row.provider),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -488,6 +488,7 @@ export function resolveKnownHostRoute(input: {
|
|||||||
export const SETTINGS_SECTION_SLUGS = [
|
export const SETTINGS_SECTION_SLUGS = [
|
||||||
"general",
|
"general",
|
||||||
"appearance",
|
"appearance",
|
||||||
|
"editor",
|
||||||
"shortcuts",
|
"shortcuts",
|
||||||
"integrations",
|
"integrations",
|
||||||
"permissions",
|
"permissions",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import type {
|
|||||||
FileDownloadTokenResponse,
|
FileDownloadTokenResponse,
|
||||||
FileUploadResponse,
|
FileUploadResponse,
|
||||||
FileExplorerResponse,
|
FileExplorerResponse,
|
||||||
|
FileVersion,
|
||||||
|
FileWriteResult,
|
||||||
FetchAgentTimelineResponseMessage,
|
FetchAgentTimelineResponseMessage,
|
||||||
AgentForkContextResponseMessage,
|
AgentForkContextResponseMessage,
|
||||||
GitSetupOptions,
|
GitSetupOptions,
|
||||||
@@ -416,6 +418,7 @@ export interface FileReadResult {
|
|||||||
path: string;
|
path: string;
|
||||||
kind: LegacyFileExplorerFilePayload["kind"];
|
kind: LegacyFileExplorerFilePayload["kind"];
|
||||||
modifiedAt: string;
|
modifiedAt: string;
|
||||||
|
revision?: string;
|
||||||
}
|
}
|
||||||
export interface FileUploadInput {
|
export interface FileUploadInput {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -875,6 +878,7 @@ interface BinaryFileTransferState extends PendingBinaryFileRead {
|
|||||||
{ opcode: typeof FileTransferOpcode.FileBegin }
|
{ opcode: typeof FileTransferOpcode.FileBegin }
|
||||||
>["metadata"]["encoding"];
|
>["metadata"]["encoding"];
|
||||||
modifiedAt: string;
|
modifiedAt: string;
|
||||||
|
revision?: string;
|
||||||
chunks: Uint8Array[];
|
chunks: Uint8Array[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -995,6 +999,7 @@ function legacyExplorerFileToBytes(file: LegacyFileExplorerFilePayload): FileRea
|
|||||||
path: file.path,
|
path: file.path,
|
||||||
kind: file.kind,
|
kind: file.kind,
|
||||||
modifiedAt: file.modifiedAt,
|
modifiedAt: file.modifiedAt,
|
||||||
|
revision: file.revision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1098,6 +1103,10 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
private terminalDirectorySubscriptions = new Map<string, { cwd: string; workspaceId?: string }>();
|
private terminalDirectorySubscriptions = new Map<string, { cwd: string; workspaceId?: string }>();
|
||||||
|
private fileSubscriptions = new Map<
|
||||||
|
string,
|
||||||
|
{ cwd: string; path: string; onUpdate: (version: FileVersion) => void }
|
||||||
|
>();
|
||||||
private readonly terminalStreams = new TerminalStreamRouter();
|
private readonly terminalStreams = new TerminalStreamRouter();
|
||||||
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
|
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
|
||||||
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
|
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
|
||||||
@@ -1378,6 +1387,7 @@ export class DaemonClient {
|
|||||||
this.rejectPendingSendQueue(new Error("Daemon client closed"));
|
this.rejectPendingSendQueue(new Error("Daemon client closed"));
|
||||||
this.rejectPingProbe(new Error("Daemon client closed"));
|
this.rejectPingProbe(new Error("Daemon client closed"));
|
||||||
this.terminalStreams.clearSlots();
|
this.terminalStreams.clearSlots();
|
||||||
|
this.fileSubscriptions.clear();
|
||||||
this.lastServerInfoMessage = null;
|
this.lastServerInfoMessage = null;
|
||||||
if (this.runtimeMetricsInterval) {
|
if (this.runtimeMetricsInterval) {
|
||||||
clearInterval(this.runtimeMetricsInterval);
|
clearInterval(this.runtimeMetricsInterval);
|
||||||
@@ -2269,6 +2279,22 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resubscribeFileSubscriptions(): void {
|
||||||
|
for (const [subscriptionId, subscription] of this.fileSubscriptions) {
|
||||||
|
void this.sendCorrelatedSessionRequest({
|
||||||
|
message: {
|
||||||
|
type: "fs.file.subscribe.request",
|
||||||
|
cwd: subscription.cwd,
|
||||||
|
path: subscription.path,
|
||||||
|
subscriptionId,
|
||||||
|
},
|
||||||
|
responseType: "fs.file.subscribe.response",
|
||||||
|
})
|
||||||
|
.then((payload) => subscription.onUpdate(payload.initial))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Agent Lifecycle
|
// Agent Lifecycle
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -4074,6 +4100,52 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async subscribeFile(
|
||||||
|
input: { cwd: string; path: string },
|
||||||
|
onUpdate: (version: FileVersion) => void,
|
||||||
|
): Promise<{ initial: FileVersion; unsubscribe: () => void }> {
|
||||||
|
const subscriptionId = this.createRequestId();
|
||||||
|
this.fileSubscriptions.set(subscriptionId, { ...input, onUpdate });
|
||||||
|
try {
|
||||||
|
const payload = await this.sendCorrelatedSessionRequest({
|
||||||
|
message: {
|
||||||
|
type: "fs.file.subscribe.request",
|
||||||
|
cwd: input.cwd,
|
||||||
|
path: input.path,
|
||||||
|
subscriptionId,
|
||||||
|
},
|
||||||
|
responseType: "fs.file.subscribe.response",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
initial: payload.initial,
|
||||||
|
unsubscribe: () => {
|
||||||
|
if (!this.fileSubscriptions.delete(subscriptionId)) return;
|
||||||
|
void this.sendCorrelatedSessionRequest({
|
||||||
|
message: { type: "fs.file.unsubscribe.request", subscriptionId },
|
||||||
|
responseType: "fs.file.unsubscribe.response",
|
||||||
|
}).catch(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.fileSubscriptions.delete(subscriptionId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(input: {
|
||||||
|
cwd: string;
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
expectedModifiedAt: string;
|
||||||
|
expectedRevision?: string;
|
||||||
|
}): Promise<FileWriteResult> {
|
||||||
|
const payload = await this.sendCorrelatedSessionRequest({
|
||||||
|
message: { type: "fs.file.write.request", ...input },
|
||||||
|
responseType: "fs.file.write.response",
|
||||||
|
});
|
||||||
|
return payload.result;
|
||||||
|
}
|
||||||
|
|
||||||
async uploadFile(input: FileUploadInput): Promise<FileUploadResult> {
|
async uploadFile(input: FileUploadInput): Promise<FileUploadResult> {
|
||||||
const bytes = asUint8Array(input.bytes);
|
const bytes = asUint8Array(input.bytes);
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
@@ -5320,6 +5392,7 @@ export class DaemonClient {
|
|||||||
size: frame.metadata.size,
|
size: frame.metadata.size,
|
||||||
encoding: frame.metadata.encoding,
|
encoding: frame.metadata.encoding,
|
||||||
modifiedAt: frame.metadata.modifiedAt,
|
modifiedAt: frame.metadata.modifiedAt,
|
||||||
|
revision: frame.metadata.revision,
|
||||||
chunks: [],
|
chunks: [],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -5344,6 +5417,7 @@ export class DaemonClient {
|
|||||||
path: transfer.path,
|
path: transfer.path,
|
||||||
kind: binaryFileKind(transfer.mime, transfer.encoding),
|
kind: binaryFileKind(transfer.mime, transfer.encoding),
|
||||||
modifiedAt: transfer.modifiedAt,
|
modifiedAt: transfer.modifiedAt,
|
||||||
|
revision: transfer.revision,
|
||||||
});
|
});
|
||||||
this.handleSessionMessage({
|
this.handleSessionMessage({
|
||||||
type: "file_explorer_response",
|
type: "file_explorer_response",
|
||||||
@@ -5523,6 +5597,7 @@ export class DaemonClient {
|
|||||||
this.startLivenessHeartbeat();
|
this.startLivenessHeartbeat();
|
||||||
this.resubscribeCheckoutDiffSubscriptions();
|
this.resubscribeCheckoutDiffSubscriptions();
|
||||||
this.resubscribeTerminalDirectorySubscriptions();
|
this.resubscribeTerminalDirectorySubscriptions();
|
||||||
|
this.resubscribeFileSubscriptions();
|
||||||
this.flushPendingSendQueue();
|
this.flushPendingSendQueue();
|
||||||
this.resolveConnect();
|
this.resolveConnect();
|
||||||
}
|
}
|
||||||
@@ -5533,6 +5608,12 @@ export class DaemonClient {
|
|||||||
this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId);
|
this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (consumerMessage.type === "fs.file.update") {
|
||||||
|
this.fileSubscriptions
|
||||||
|
.get(consumerMessage.payload.subscriptionId)
|
||||||
|
?.onUpdate(consumerMessage.payload.version);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.rawMessageListeners.size > 0) {
|
if (this.rawMessageListeners.size > 0) {
|
||||||
for (const handler of this.rawMessageListeners) {
|
for (const handler of this.rawMessageListeners) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"typecheck": "tsgo --noEmit"
|
"typecheck": "tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { isLanguageSupported, getSupportedExtensions, getParserForFile } from "../parsers.js";
|
import {
|
||||||
|
isLanguageSupported,
|
||||||
|
getSupportedExtensions,
|
||||||
|
getParserForFile,
|
||||||
|
getLanguageForFile,
|
||||||
|
} from "../parsers.js";
|
||||||
|
|
||||||
describe("isLanguageSupported", () => {
|
describe("isLanguageSupported", () => {
|
||||||
it("returns true for supported file extensions", () => {
|
it("returns true for supported file extensions", () => {
|
||||||
@@ -60,6 +65,13 @@ describe("getSupportedExtensions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getParserForFile", () => {
|
describe("getParserForFile", () => {
|
||||||
|
it("projects the parser retained by the editor language registry", () => {
|
||||||
|
for (const extension of getSupportedExtensions()) {
|
||||||
|
const filename = `source.${extension}`;
|
||||||
|
expect(getParserForFile(filename)).toBe(getLanguageForFile(filename)?.parser);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("returns a parser for supported files", () => {
|
it("returns a parser for supported files", () => {
|
||||||
expect(getParserForFile("test.js")).not.toBeNull();
|
expect(getParserForFile("test.js")).not.toBeNull();
|
||||||
expect(getParserForFile("test.py")).not.toBeNull();
|
expect(getParserForFile("test.py")).not.toBeNull();
|
||||||
|
|||||||
@@ -1,50 +1,8 @@
|
|||||||
import { highlightTree, tagHighlighter, tags } from "@lezer/highlight";
|
import { highlightTree } from "@lezer/highlight";
|
||||||
import type { HighlightStyle, HighlightToken } from "./types.js";
|
import type { HighlightStyle, HighlightToken } from "./types.js";
|
||||||
import { getParserForFile } from "./parsers.js";
|
import { getParserForFile } from "./parsers.js";
|
||||||
|
|
||||||
const highlighter = tagHighlighter([
|
import { staticSyntaxHighlighter } from "./syntax-roles.js";
|
||||||
{ tag: tags.keyword, class: "keyword" },
|
|
||||||
{ tag: tags.controlKeyword, class: "keyword" },
|
|
||||||
{ tag: tags.operatorKeyword, class: "keyword" },
|
|
||||||
{ tag: tags.definitionKeyword, class: "keyword" },
|
|
||||||
{ tag: tags.moduleKeyword, class: "keyword" },
|
|
||||||
{ tag: tags.comment, class: "comment" },
|
|
||||||
{ tag: tags.lineComment, class: "comment" },
|
|
||||||
{ tag: tags.blockComment, class: "comment" },
|
|
||||||
{ tag: tags.docComment, class: "comment" },
|
|
||||||
{ tag: tags.string, class: "string" },
|
|
||||||
{ tag: tags.special(tags.string), class: "string" },
|
|
||||||
{ tag: tags.number, class: "number" },
|
|
||||||
{ tag: tags.integer, class: "number" },
|
|
||||||
{ tag: tags.float, class: "number" },
|
|
||||||
{ tag: tags.bool, class: "literal" },
|
|
||||||
{ tag: tags.null, class: "literal" },
|
|
||||||
{ tag: tags.function(tags.variableName), class: "function" },
|
|
||||||
{ tag: tags.function(tags.propertyName), class: "function" },
|
|
||||||
{ tag: tags.definition(tags.variableName), class: "definition" },
|
|
||||||
{ tag: tags.definition(tags.propertyName), class: "definition" },
|
|
||||||
{ tag: tags.definition(tags.function(tags.variableName)), class: "definition" },
|
|
||||||
{ tag: tags.className, class: "class" },
|
|
||||||
{ tag: tags.definition(tags.className), class: "class" },
|
|
||||||
{ tag: tags.typeName, class: "type" },
|
|
||||||
{ tag: tags.tagName, class: "tag" },
|
|
||||||
{ tag: tags.attributeName, class: "attribute" },
|
|
||||||
{ tag: tags.attributeValue, class: "string" },
|
|
||||||
{ tag: tags.propertyName, class: "property" },
|
|
||||||
{ tag: tags.variableName, class: "variable" },
|
|
||||||
{ tag: tags.local(tags.variableName), class: "variable" },
|
|
||||||
{ tag: tags.special(tags.variableName), class: "variable" },
|
|
||||||
{ tag: tags.operator, class: "operator" },
|
|
||||||
{ tag: tags.punctuation, class: "punctuation" },
|
|
||||||
{ tag: tags.bracket, class: "punctuation" },
|
|
||||||
{ tag: tags.separator, class: "punctuation" },
|
|
||||||
{ tag: tags.regexp, class: "regexp" },
|
|
||||||
{ tag: tags.escape, class: "escape" },
|
|
||||||
{ tag: tags.meta, class: "meta" },
|
|
||||||
{ tag: tags.heading, class: "heading" },
|
|
||||||
{ tag: tags.link, class: "link" },
|
|
||||||
{ tag: tags.url, class: "link" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function highlightCode(code: string, filename: string): HighlightToken[][] {
|
export function highlightCode(code: string, filename: string): HighlightToken[][] {
|
||||||
const parser = getParserForFile(filename);
|
const parser = getParserForFile(filename);
|
||||||
@@ -64,7 +22,7 @@ export function highlightCode(code: string, filename: string): HighlightToken[][
|
|||||||
// Build a map of character positions to styles
|
// Build a map of character positions to styles
|
||||||
const styleMap: Array<HighlightStyle | null> = Array.from({ length: code.length }, () => null);
|
const styleMap: Array<HighlightStyle | null> = Array.from({ length: code.length }, () => null);
|
||||||
|
|
||||||
highlightTree(tree, highlighter, (from, to, classes) => {
|
highlightTree(tree, staticSyntaxHighlighter, (from, to, classes) => {
|
||||||
for (let i = from; i < to && i < styleMap.length; i++) {
|
for (let i = from; i < to && i < styleMap.length; i++) {
|
||||||
styleMap[i] = classes as HighlightStyle;
|
styleMap[i] = classes as HighlightStyle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
export type { HighlightStyle, HighlightToken } from "./types.js";
|
export type { HighlightStyle, HighlightToken } from "./types.js";
|
||||||
export { getParserForFile, isLanguageSupported, getSupportedExtensions } from "./parsers.js";
|
export {
|
||||||
|
getLanguageForFile,
|
||||||
|
getParserForFile,
|
||||||
|
isLanguageSupported,
|
||||||
|
getSupportedExtensions,
|
||||||
|
} from "./parsers.js";
|
||||||
|
export { createCodeMirrorHighlightStyle } from "./syntax-roles.js";
|
||||||
export { highlightCode, highlightLine } from "./highlighter.js";
|
export { highlightCode, highlightLine } from "./highlighter.js";
|
||||||
export { darkHighlightColors, lightHighlightColors } from "./colors.js";
|
export { darkHighlightColors, lightHighlightColors } from "./colors.js";
|
||||||
export type { SyntaxThemeId, SyntaxThemeOption, SyntaxColors } from "./themes.js";
|
export type { SyntaxThemeId, SyntaxThemeOption, SyntaxColors } from "./themes.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { StreamLanguage } from "@codemirror/language";
|
import { defineLanguageFacet, Language, StreamLanguage } from "@codemirror/language";
|
||||||
import { dart } from "@codemirror/legacy-modes/mode/clike";
|
import { dart } from "@codemirror/legacy-modes/mode/clike";
|
||||||
import { swift } from "@codemirror/legacy-modes/mode/swift";
|
import { swift } from "@codemirror/legacy-modes/mode/swift";
|
||||||
import { parser as jsParser } from "@lezer/javascript";
|
import { parser as jsParser } from "@lezer/javascript";
|
||||||
@@ -18,65 +18,73 @@ import { csharpLanguage } from "@replit/codemirror-lang-csharp";
|
|||||||
import { parser as elixirParser } from "lezer-elixir";
|
import { parser as elixirParser } from "lezer-elixir";
|
||||||
import type { Parser } from "@lezer/common";
|
import type { Parser } from "@lezer/common";
|
||||||
|
|
||||||
const parsersByExtension: Record<string, Parser> = {
|
function language(parser: Parser): Language {
|
||||||
|
return new Language(defineLanguageFacet(), parser);
|
||||||
|
}
|
||||||
|
|
||||||
|
const languagesByExtension: Record<string, Language> = {
|
||||||
// JavaScript/TypeScript
|
// JavaScript/TypeScript
|
||||||
js: jsParser,
|
js: language(jsParser),
|
||||||
jsx: jsParser.configure({ dialect: "jsx" }),
|
jsx: language(jsParser.configure({ dialect: "jsx" })),
|
||||||
ts: jsParser.configure({ dialect: "ts" }),
|
ts: language(jsParser.configure({ dialect: "ts" })),
|
||||||
tsx: jsParser.configure({ dialect: "ts jsx" }),
|
tsx: language(jsParser.configure({ dialect: "ts jsx" })),
|
||||||
mjs: jsParser,
|
mjs: language(jsParser),
|
||||||
cjs: jsParser,
|
cjs: language(jsParser),
|
||||||
// C / C++ / Objective-C
|
// C / C++ / Objective-C
|
||||||
c: cppParser,
|
c: language(cppParser),
|
||||||
h: cppParser,
|
h: language(cppParser),
|
||||||
cc: cppParser,
|
cc: language(cppParser),
|
||||||
cpp: cppParser,
|
cpp: language(cppParser),
|
||||||
cxx: cppParser,
|
cxx: language(cppParser),
|
||||||
hpp: cppParser,
|
hpp: language(cppParser),
|
||||||
hxx: cppParser,
|
hxx: language(cppParser),
|
||||||
m: cppParser,
|
m: language(cppParser),
|
||||||
mm: cppParser,
|
mm: language(cppParser),
|
||||||
// JSON
|
// JSON
|
||||||
json: jsonParser,
|
json: language(jsonParser),
|
||||||
// CSS
|
// CSS
|
||||||
css: cssParser,
|
css: language(cssParser),
|
||||||
scss: cssParser,
|
scss: language(cssParser),
|
||||||
// HTML
|
// HTML
|
||||||
html: htmlParser,
|
html: language(htmlParser),
|
||||||
htm: htmlParser,
|
htm: language(htmlParser),
|
||||||
// XML
|
// XML
|
||||||
xml: xmlParser,
|
xml: language(xmlParser),
|
||||||
// Java
|
// Java
|
||||||
java: javaParser,
|
java: language(javaParser),
|
||||||
// Python
|
// Python
|
||||||
py: pythonParser,
|
py: language(pythonParser),
|
||||||
// Go
|
// Go
|
||||||
go: goParser,
|
go: language(goParser),
|
||||||
// PHP
|
// PHP
|
||||||
php: phpParser,
|
php: language(phpParser),
|
||||||
// YAML
|
// YAML
|
||||||
yaml: yamlParser,
|
yaml: language(yamlParser),
|
||||||
yml: yamlParser,
|
yml: language(yamlParser),
|
||||||
// Rust
|
// Rust
|
||||||
rs: rustParser,
|
rs: language(rustParser),
|
||||||
// Swift
|
// Swift
|
||||||
swift: StreamLanguage.define(swift).parser,
|
swift: StreamLanguage.define(swift),
|
||||||
// Dart
|
// Dart
|
||||||
dart: StreamLanguage.define(dart).parser,
|
dart: StreamLanguage.define(dart),
|
||||||
// C#
|
// C#
|
||||||
cs: csharpLanguage.parser,
|
cs: csharpLanguage,
|
||||||
// Elixir
|
// Elixir
|
||||||
ex: elixirParser,
|
ex: language(elixirParser),
|
||||||
exs: elixirParser,
|
exs: language(elixirParser),
|
||||||
// Markdown
|
// Markdown
|
||||||
md: markdownParser,
|
md: language(markdownParser),
|
||||||
mdx: markdownParser,
|
mdx: language(markdownParser),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getParserForFile(filename: string): Parser | null {
|
export function getLanguageForFile(filename: string): Language | null {
|
||||||
const ext = filename.split(".").pop()?.toLowerCase();
|
const ext = filename.split(".").pop()?.toLowerCase();
|
||||||
if (!ext) return null;
|
if (!ext) return null;
|
||||||
return parsersByExtension[ext] ?? null;
|
return languagesByExtension[ext] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParserForFile(filename: string): Parser | null {
|
||||||
|
return getLanguageForFile(filename)?.parser ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLanguageSupported(filename: string): boolean {
|
export function isLanguageSupported(filename: string): boolean {
|
||||||
@@ -84,5 +92,5 @@ export function isLanguageSupported(filename: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSupportedExtensions(): string[] {
|
export function getSupportedExtensions(): string[] {
|
||||||
return Object.keys(parsersByExtension);
|
return Object.keys(languagesByExtension);
|
||||||
}
|
}
|
||||||
|
|||||||
57
packages/highlight/src/syntax-roles.ts
Normal file
57
packages/highlight/src/syntax-roles.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { HighlightStyle as CodeMirrorHighlightStyle } from "@codemirror/language";
|
||||||
|
import { tagHighlighter, tags, type Tag } from "@lezer/highlight";
|
||||||
|
import type { HighlightStyle } from "./types.js";
|
||||||
|
|
||||||
|
export const syntaxRoleTags: ReadonlyArray<{ tag: Tag; role: HighlightStyle }> = [
|
||||||
|
{ tag: tags.keyword, role: "keyword" },
|
||||||
|
{ tag: tags.controlKeyword, role: "keyword" },
|
||||||
|
{ tag: tags.operatorKeyword, role: "keyword" },
|
||||||
|
{ tag: tags.definitionKeyword, role: "keyword" },
|
||||||
|
{ tag: tags.moduleKeyword, role: "keyword" },
|
||||||
|
{ tag: tags.comment, role: "comment" },
|
||||||
|
{ tag: tags.lineComment, role: "comment" },
|
||||||
|
{ tag: tags.blockComment, role: "comment" },
|
||||||
|
{ tag: tags.docComment, role: "comment" },
|
||||||
|
{ tag: tags.string, role: "string" },
|
||||||
|
{ tag: tags.special(tags.string), role: "string" },
|
||||||
|
{ tag: tags.number, role: "number" },
|
||||||
|
{ tag: tags.integer, role: "number" },
|
||||||
|
{ tag: tags.float, role: "number" },
|
||||||
|
{ tag: tags.bool, role: "literal" },
|
||||||
|
{ tag: tags.null, role: "literal" },
|
||||||
|
{ tag: tags.function(tags.variableName), role: "function" },
|
||||||
|
{ tag: tags.function(tags.propertyName), role: "function" },
|
||||||
|
{ tag: tags.definition(tags.variableName), role: "definition" },
|
||||||
|
{ tag: tags.definition(tags.propertyName), role: "definition" },
|
||||||
|
{ tag: tags.definition(tags.function(tags.variableName)), role: "definition" },
|
||||||
|
{ tag: tags.className, role: "class" },
|
||||||
|
{ tag: tags.definition(tags.className), role: "class" },
|
||||||
|
{ tag: tags.typeName, role: "type" },
|
||||||
|
{ tag: tags.tagName, role: "tag" },
|
||||||
|
{ tag: tags.attributeName, role: "attribute" },
|
||||||
|
{ tag: tags.attributeValue, role: "string" },
|
||||||
|
{ tag: tags.propertyName, role: "property" },
|
||||||
|
{ tag: tags.variableName, role: "variable" },
|
||||||
|
{ tag: tags.local(tags.variableName), role: "variable" },
|
||||||
|
{ tag: tags.special(tags.variableName), role: "variable" },
|
||||||
|
{ tag: tags.operator, role: "operator" },
|
||||||
|
{ tag: tags.punctuation, role: "punctuation" },
|
||||||
|
{ tag: tags.bracket, role: "punctuation" },
|
||||||
|
{ tag: tags.separator, role: "punctuation" },
|
||||||
|
{ tag: tags.regexp, role: "regexp" },
|
||||||
|
{ tag: tags.escape, role: "escape" },
|
||||||
|
{ tag: tags.meta, role: "meta" },
|
||||||
|
{ tag: tags.heading, role: "heading" },
|
||||||
|
{ tag: tags.link, role: "link" },
|
||||||
|
{ tag: tags.url, role: "link" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const staticSyntaxHighlighter = tagHighlighter(
|
||||||
|
syntaxRoleTags.map(({ tag, role }) => ({ tag, class: role })),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function createCodeMirrorHighlightStyle(colors: Record<HighlightStyle, string>) {
|
||||||
|
return CodeMirrorHighlightStyle.define(
|
||||||
|
syntaxRoleTags.map(({ tag, role }) => ({ tag, color: colors[role] })),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export const FileBeginMetadataSchema = z.object({
|
|||||||
size: z.number().int().nonnegative(),
|
size: z.number().int().nonnegative(),
|
||||||
encoding: z.enum(["utf-8", "binary"]),
|
encoding: z.enum(["utf-8", "binary"]),
|
||||||
modifiedAt: z.string(),
|
modifiedAt: z.string(),
|
||||||
|
revision: z.string().optional(),
|
||||||
fileName: z.string().optional(),
|
fileName: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
97
packages/protocol/src/messages.file-editing.test.ts
Normal file
97
packages/protocol/src/messages.file-editing.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
FileSubscribeRequestSchema,
|
||||||
|
FileSubscribeResponseSchema,
|
||||||
|
FileUnsubscribeRequestSchema,
|
||||||
|
FileUpdateSchema,
|
||||||
|
FileWriteRequestSchema,
|
||||||
|
FileWriteResponseSchema,
|
||||||
|
ServerInfoStatusPayloadSchema,
|
||||||
|
} from "./messages.js";
|
||||||
|
|
||||||
|
describe("workspace file editing messages", () => {
|
||||||
|
test("keeps the capability optional for older server info payloads", () => {
|
||||||
|
expect(
|
||||||
|
ServerInfoStatusPayloadSchema.parse({
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "server-1",
|
||||||
|
features: {},
|
||||||
|
}).features?.workspaceFileEditing,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses subscribe, unsubscribe, and version update messages", () => {
|
||||||
|
expect(
|
||||||
|
FileSubscribeRequestSchema.parse({
|
||||||
|
type: "fs.file.subscribe.request",
|
||||||
|
cwd: "/workspace",
|
||||||
|
path: "file.ts",
|
||||||
|
subscriptionId: "subscription-1",
|
||||||
|
requestId: "request-1",
|
||||||
|
}).subscriptionId,
|
||||||
|
).toBe("subscription-1");
|
||||||
|
expect(
|
||||||
|
FileSubscribeResponseSchema.parse({
|
||||||
|
type: "fs.file.subscribe.response",
|
||||||
|
payload: {
|
||||||
|
subscriptionId: "subscription-1",
|
||||||
|
initial: {
|
||||||
|
status: "ready",
|
||||||
|
cwd: "/workspace",
|
||||||
|
path: "file.ts",
|
||||||
|
size: 12,
|
||||||
|
modifiedAt: "2026-07-18T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
requestId: "request-1",
|
||||||
|
},
|
||||||
|
}).payload.initial.status,
|
||||||
|
).toBe("ready");
|
||||||
|
expect(
|
||||||
|
FileUnsubscribeRequestSchema.parse({
|
||||||
|
type: "fs.file.unsubscribe.request",
|
||||||
|
subscriptionId: "subscription-1",
|
||||||
|
requestId: "request-2",
|
||||||
|
}).subscriptionId,
|
||||||
|
).toBe("subscription-1");
|
||||||
|
expect(
|
||||||
|
FileUpdateSchema.parse({
|
||||||
|
type: "fs.file.update",
|
||||||
|
payload: {
|
||||||
|
subscriptionId: "subscription-1",
|
||||||
|
version: { status: "missing", cwd: "/workspace", path: "file.ts" },
|
||||||
|
},
|
||||||
|
}).payload.version.status,
|
||||||
|
).toBe("missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses optimistic write requests and conflict responses", () => {
|
||||||
|
expect(
|
||||||
|
FileWriteRequestSchema.parse({
|
||||||
|
type: "fs.file.write.request",
|
||||||
|
cwd: "/workspace",
|
||||||
|
path: "file.ts",
|
||||||
|
content: "const value = 1;\n",
|
||||||
|
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
|
||||||
|
requestId: "request-1",
|
||||||
|
}).content,
|
||||||
|
).toBe("const value = 1;\n");
|
||||||
|
expect(
|
||||||
|
FileWriteResponseSchema.parse({
|
||||||
|
type: "fs.file.write.response",
|
||||||
|
payload: {
|
||||||
|
result: {
|
||||||
|
status: "conflict",
|
||||||
|
version: {
|
||||||
|
status: "ready",
|
||||||
|
cwd: "/workspace",
|
||||||
|
path: "file.ts",
|
||||||
|
size: 20,
|
||||||
|
modifiedAt: "2026-07-18T00:00:01.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requestId: "request-1",
|
||||||
|
},
|
||||||
|
}).payload.result.status,
|
||||||
|
).toBe("conflict");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2132,6 +2132,7 @@ const FileExplorerFileSchema = z.object({
|
|||||||
mimeType: z.string().optional(),
|
mimeType: z.string().optional(),
|
||||||
size: z.number(),
|
size: z.number(),
|
||||||
modifiedAt: z.string(),
|
modifiedAt: z.string(),
|
||||||
|
revision: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const FileExplorerDirectorySchema = z.object({
|
const FileExplorerDirectorySchema = z.object({
|
||||||
@@ -2148,6 +2149,52 @@ export const FileExplorerRequestSchema = z.object({
|
|||||||
acceptBinary: z.boolean().optional(),
|
acceptBinary: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const FileVersionSchema = z.discriminatedUnion("status", [
|
||||||
|
z.object({
|
||||||
|
status: z.literal("ready"),
|
||||||
|
cwd: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
size: z.number().int().nonnegative(),
|
||||||
|
modifiedAt: z.string(),
|
||||||
|
revision: z.string().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
status: z.literal("missing"),
|
||||||
|
cwd: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
status: z.literal("error"),
|
||||||
|
cwd: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
error: z.string(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const FileSubscribeRequestSchema = z.object({
|
||||||
|
type: z.literal("fs.file.subscribe.request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
subscriptionId: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileUnsubscribeRequestSchema = z.object({
|
||||||
|
type: z.literal("fs.file.unsubscribe.request"),
|
||||||
|
subscriptionId: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileWriteRequestSchema = z.object({
|
||||||
|
type: z.literal("fs.file.write.request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
expectedModifiedAt: z.string(),
|
||||||
|
expectedRevision: z.string().optional(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const ProjectIconRequestSchema = z.object({
|
export const ProjectIconRequestSchema = z.object({
|
||||||
type: z.literal("project_icon_request"),
|
type: z.literal("project_icon_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -2463,6 +2510,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
WorkspaceCreateRequestSchema,
|
WorkspaceCreateRequestSchema,
|
||||||
WorkspaceClearAttentionRequestSchema,
|
WorkspaceClearAttentionRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
FileExplorerRequestSchema,
|
||||||
|
FileSubscribeRequestSchema,
|
||||||
|
FileUnsubscribeRequestSchema,
|
||||||
|
FileWriteRequestSchema,
|
||||||
ProjectIconRequestSchema,
|
ProjectIconRequestSchema,
|
||||||
FileDownloadTokenRequestSchema,
|
FileDownloadTokenRequestSchema,
|
||||||
FileUploadRequestSchema,
|
FileUploadRequestSchema,
|
||||||
@@ -2698,6 +2748,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
worktreeRestore: z.boolean().optional(),
|
worktreeRestore: z.boolean().optional(),
|
||||||
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
|
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
|
||||||
workspaceRecovery: z.boolean().optional(),
|
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(),
|
||||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||||
providerUsageList: z.boolean().optional(),
|
providerUsageList: z.boolean().optional(),
|
||||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||||
@@ -4552,6 +4604,50 @@ export const FileExplorerResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const FileSubscribeResponseSchema = z.object({
|
||||||
|
type: z.literal("fs.file.subscribe.response"),
|
||||||
|
payload: z.object({
|
||||||
|
subscriptionId: z.string(),
|
||||||
|
initial: FileVersionSchema,
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileUnsubscribeResponseSchema = z.object({
|
||||||
|
type: z.literal("fs.file.unsubscribe.response"),
|
||||||
|
payload: z.object({
|
||||||
|
subscriptionId: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileWriteResultSchema = z.discriminatedUnion("status", [
|
||||||
|
z.object({
|
||||||
|
status: z.literal("written"),
|
||||||
|
modifiedAt: z.string(),
|
||||||
|
size: z.number(),
|
||||||
|
revision: z.string().optional(),
|
||||||
|
}),
|
||||||
|
z.object({ status: z.literal("conflict"), version: FileVersionSchema }),
|
||||||
|
z.object({ status: z.literal("error"), error: z.string() }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const FileWriteResponseSchema = z.object({
|
||||||
|
type: z.literal("fs.file.write.response"),
|
||||||
|
payload: z.object({
|
||||||
|
result: FileWriteResultSchema,
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileUpdateSchema = z.object({
|
||||||
|
type: z.literal("fs.file.update"),
|
||||||
|
payload: z.object({
|
||||||
|
subscriptionId: z.string(),
|
||||||
|
version: FileVersionSchema,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const ProjectIconSchema = z.object({
|
const ProjectIconSchema = z.object({
|
||||||
data: z.string(),
|
data: z.string(),
|
||||||
mimeType: z.string(),
|
mimeType: z.string(),
|
||||||
@@ -5099,6 +5195,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
PaseoWorktreeArchiveResponseSchema,
|
PaseoWorktreeArchiveResponseSchema,
|
||||||
CreatePaseoWorktreeResponseSchema,
|
CreatePaseoWorktreeResponseSchema,
|
||||||
FileExplorerResponseSchema,
|
FileExplorerResponseSchema,
|
||||||
|
FileSubscribeResponseSchema,
|
||||||
|
FileUnsubscribeResponseSchema,
|
||||||
|
FileWriteResponseSchema,
|
||||||
|
FileUpdateSchema,
|
||||||
ProjectIconResponseSchema,
|
ProjectIconResponseSchema,
|
||||||
FileDownloadTokenResponseSchema,
|
FileDownloadTokenResponseSchema,
|
||||||
FileUploadResponseSchema,
|
FileUploadResponseSchema,
|
||||||
@@ -5493,6 +5593,15 @@ export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSche
|
|||||||
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
|
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
|
||||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||||
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
||||||
|
export type FileVersion = z.infer<typeof FileVersionSchema>;
|
||||||
|
export type FileSubscribeRequest = z.infer<typeof FileSubscribeRequestSchema>;
|
||||||
|
export type FileSubscribeResponse = z.infer<typeof FileSubscribeResponseSchema>;
|
||||||
|
export type FileUnsubscribeRequest = z.infer<typeof FileUnsubscribeRequestSchema>;
|
||||||
|
export type FileUnsubscribeResponse = z.infer<typeof FileUnsubscribeResponseSchema>;
|
||||||
|
export type FileWriteRequest = z.infer<typeof FileWriteRequestSchema>;
|
||||||
|
export type FileWriteResponse = z.infer<typeof FileWriteResponseSchema>;
|
||||||
|
export type FileWriteResult = z.infer<typeof FileWriteResultSchema>;
|
||||||
|
export type FileUpdate = z.infer<typeof FileUpdateSchema>;
|
||||||
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
|
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
|
||||||
export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
|
export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
|
||||||
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
|
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
|
||||||
|
|||||||
139
packages/server/src/server/file-explorer/observer.test.ts
Normal file
139
packages/server/src/server/file-explorer/observer.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { mkdtemp, rm, symlink, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
import { FileObserver, type FileObserverDependencies } from "./observer.js";
|
||||||
|
|
||||||
|
class ObservationControls implements FileObserverDependencies {
|
||||||
|
watches = 0;
|
||||||
|
closes = 0;
|
||||||
|
private change: ((filename: string | null) => void) | null = null;
|
||||||
|
private timeout: (() => void | Promise<void>) | null = null;
|
||||||
|
private interval: (() => void | Promise<void>) | null = null;
|
||||||
|
|
||||||
|
watchDirectory(_directory: string, onChange: (filename: string | null) => void) {
|
||||||
|
this.watches += 1;
|
||||||
|
this.change = onChange;
|
||||||
|
return { close: () => (this.closes += 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(callback: () => void | Promise<void>): ReturnType<typeof setTimeout> {
|
||||||
|
this.timeout = callback;
|
||||||
|
return 1 as ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(): void {
|
||||||
|
this.timeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(callback: () => void | Promise<void>): ReturnType<typeof setInterval> {
|
||||||
|
this.interval = callback;
|
||||||
|
return 2 as ReturnType<typeof setInterval>;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(): void {
|
||||||
|
this.interval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fileChanged(filename: string): Promise<void> {
|
||||||
|
this.change?.(filename);
|
||||||
|
const timeout = this.timeout;
|
||||||
|
this.timeout = null;
|
||||||
|
await timeout?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function workspace() {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-file-observer-"));
|
||||||
|
roots.push(root);
|
||||||
|
await writeFile(path.join(root, "file.txt"), "one", "utf8");
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FileObserver", () => {
|
||||||
|
test("shares one parent watcher and publishes each real version once", async () => {
|
||||||
|
const root = await workspace();
|
||||||
|
const controls = new ObservationControls();
|
||||||
|
const observer = new FileObserver(controls);
|
||||||
|
const firstUpdates: string[] = [];
|
||||||
|
const secondUpdates: string[] = [];
|
||||||
|
const first = await observer.subscribe({ cwd: root, path: "file.txt" }, (version) =>
|
||||||
|
firstUpdates.push(version.status),
|
||||||
|
);
|
||||||
|
const second = await observer.subscribe({ cwd: root, path: "file.txt" }, (version) =>
|
||||||
|
secondUpdates.push(version.status),
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(path.join(root, "file.txt"), "changed content", "utf8");
|
||||||
|
await controls.fileChanged("file.txt");
|
||||||
|
await controls.fileChanged("file.txt");
|
||||||
|
|
||||||
|
expect(controls.watches).toBe(1);
|
||||||
|
expect(firstUpdates).toEqual(["ready"]);
|
||||||
|
expect(secondUpdates).toEqual(["ready"]);
|
||||||
|
first.unsubscribe();
|
||||||
|
expect(controls.closes).toBe(0);
|
||||||
|
second.unsubscribe();
|
||||||
|
expect(controls.closes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coalesces concurrent subscriptions onto one watcher", async () => {
|
||||||
|
const root = await workspace();
|
||||||
|
const controls = new ObservationControls();
|
||||||
|
const observer = new FileObserver(controls);
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
observer.subscribe({ cwd: root, path: "file.txt" }, () => undefined),
|
||||||
|
observer.subscribe({ cwd: root, path: "file.txt" }, () => undefined),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(controls.watches).toBe(1);
|
||||||
|
first.unsubscribe();
|
||||||
|
second.unsubscribe();
|
||||||
|
expect(controls.closes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publishes shared watcher updates in each subscriber's path coordinates", async () => {
|
||||||
|
const root = await workspace();
|
||||||
|
const aliasParent = await mkdtemp(path.join(os.tmpdir(), "paseo-file-observer-alias-"));
|
||||||
|
roots.push(aliasParent);
|
||||||
|
const aliasRoot = path.join(aliasParent, "workspace-link");
|
||||||
|
await symlink(root, aliasRoot, "dir");
|
||||||
|
const controls = new ObservationControls();
|
||||||
|
const observer = new FileObserver(controls);
|
||||||
|
const updates: Array<{ cwd: string; path: string }> = [];
|
||||||
|
const direct = await observer.subscribe({ cwd: root, path: "file.txt" }, () => undefined);
|
||||||
|
const alias = await observer.subscribe({ cwd: aliasRoot, path: "file.txt" }, (version) =>
|
||||||
|
updates.push({ cwd: version.cwd, path: version.path }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(path.join(root, "file.txt"), "changed content", "utf8");
|
||||||
|
await controls.fileChanged("file.txt");
|
||||||
|
|
||||||
|
expect(controls.watches).toBe(1);
|
||||||
|
expect(updates).toEqual([{ cwd: aliasRoot, path: "file.txt" }]);
|
||||||
|
direct.unsubscribe();
|
||||||
|
alias.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publishes deletion without dropping the subscription", async () => {
|
||||||
|
const root = await workspace();
|
||||||
|
const controls = new ObservationControls();
|
||||||
|
const observer = new FileObserver(controls);
|
||||||
|
const updates: string[] = [];
|
||||||
|
const subscription = await observer.subscribe({ cwd: root, path: "file.txt" }, (version) =>
|
||||||
|
updates.push(version.status),
|
||||||
|
);
|
||||||
|
|
||||||
|
await unlink(path.join(root, "file.txt"));
|
||||||
|
await controls.fileChanged("file.txt");
|
||||||
|
|
||||||
|
expect(updates).toEqual(["missing"]);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
173
packages/server/src/server/file-explorer/observer.ts
Normal file
173
packages/server/src/server/file-explorer/observer.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { watch, type FSWatcher } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { FileVersion } from "@getpaseo/protocol/messages";
|
||||||
|
import { getExplorerFileVersion, resolveExplorerFilePath } from "./service.js";
|
||||||
|
|
||||||
|
interface FileWatch {
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileObserverDependencies {
|
||||||
|
watchDirectory(
|
||||||
|
directory: string,
|
||||||
|
onChange: (filename: string | null) => void,
|
||||||
|
onError: () => void,
|
||||||
|
): FileWatch;
|
||||||
|
setTimeout(callback: () => void | Promise<void>, delayMs: number): ReturnType<typeof setTimeout>;
|
||||||
|
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
||||||
|
setInterval(
|
||||||
|
callback: () => void | Promise<void>,
|
||||||
|
delayMs: number,
|
||||||
|
): ReturnType<typeof setInterval>;
|
||||||
|
clearInterval(handle: ReturnType<typeof setInterval>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObservedFile {
|
||||||
|
cwd: string;
|
||||||
|
path: string;
|
||||||
|
basename: string;
|
||||||
|
listeners: Map<(version: FileVersion) => void, { cwd: string; path: string }>;
|
||||||
|
fingerprint: string;
|
||||||
|
watcher: FileWatch | null;
|
||||||
|
debounce: ReturnType<typeof setTimeout> | null;
|
||||||
|
fallback: ReturnType<typeof setInterval> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeDependencies: FileObserverDependencies = {
|
||||||
|
watchDirectory(directory, onChange, onError) {
|
||||||
|
const watcher: FSWatcher = watch(directory, (_event, filename) => {
|
||||||
|
onChange(filename === null ? null : filename.toString());
|
||||||
|
});
|
||||||
|
watcher.on("error", onError);
|
||||||
|
return watcher;
|
||||||
|
},
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
setInterval,
|
||||||
|
clearInterval,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FileObserver {
|
||||||
|
private readonly dependencies: FileObserverDependencies;
|
||||||
|
private readonly observed = new Map<string, ObservedFile>();
|
||||||
|
|
||||||
|
constructor(dependencies: FileObserverDependencies = nodeDependencies) {
|
||||||
|
this.dependencies = dependencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe(
|
||||||
|
input: { cwd: string; path: string },
|
||||||
|
listener: (version: FileVersion) => void,
|
||||||
|
): Promise<{ initial: FileVersion; unsubscribe: () => void }> {
|
||||||
|
const target = await resolveExplorerFilePath({ root: input.cwd, relativePath: input.path });
|
||||||
|
let observed = this.observed.get(target);
|
||||||
|
if (!observed) {
|
||||||
|
const initial = await getExplorerFileVersion({ root: input.cwd, relativePath: input.path });
|
||||||
|
observed = this.observed.get(target);
|
||||||
|
if (!observed) {
|
||||||
|
observed = {
|
||||||
|
cwd: input.cwd,
|
||||||
|
path: input.path,
|
||||||
|
basename: path.basename(target),
|
||||||
|
listeners: new Map(),
|
||||||
|
fingerprint: fingerprint(initial),
|
||||||
|
watcher: null,
|
||||||
|
debounce: null,
|
||||||
|
fallback: null,
|
||||||
|
};
|
||||||
|
this.observed.set(target, observed);
|
||||||
|
this.startWatching(target, path.dirname(target), observed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const initial = await getExplorerFileVersion({
|
||||||
|
root: input.cwd,
|
||||||
|
relativePath: input.path,
|
||||||
|
});
|
||||||
|
observed.listeners.set(listener, { cwd: initial.cwd, path: initial.path });
|
||||||
|
observed.fingerprint = fingerprint(initial);
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
return {
|
||||||
|
initial,
|
||||||
|
unsubscribe: () => {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
observed?.listeners.delete(listener);
|
||||||
|
if (observed && observed.listeners.size === 0) {
|
||||||
|
this.stopWatching(observed);
|
||||||
|
this.observed.delete(target);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const observed of this.observed.values()) {
|
||||||
|
this.stopWatching(observed);
|
||||||
|
}
|
||||||
|
this.observed.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startWatching(target: string, directory: string, observed: ObservedFile): void {
|
||||||
|
try {
|
||||||
|
observed.watcher = this.dependencies.watchDirectory(
|
||||||
|
directory,
|
||||||
|
(filename) => {
|
||||||
|
if (filename === null || filename === observed.basename) {
|
||||||
|
this.scheduleRestat(target, observed);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => this.useFallback(target, observed),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
this.useFallback(target, observed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private useFallback(target: string, observed: ObservedFile): void {
|
||||||
|
observed.watcher?.close();
|
||||||
|
observed.watcher = null;
|
||||||
|
if (observed.fallback) return;
|
||||||
|
observed.fallback = this.dependencies.setInterval(() => this.restate(target, observed), 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleRestat(target: string, observed: ObservedFile): void {
|
||||||
|
if (observed.debounce) {
|
||||||
|
this.dependencies.clearTimeout(observed.debounce);
|
||||||
|
}
|
||||||
|
observed.debounce = this.dependencies.setTimeout(() => {
|
||||||
|
observed.debounce = null;
|
||||||
|
return this.restate(target, observed);
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restate(target: string, observed: ObservedFile): Promise<void> {
|
||||||
|
if (this.observed.get(target) !== observed) return;
|
||||||
|
const version = await getExplorerFileVersion({
|
||||||
|
root: observed.cwd,
|
||||||
|
relativePath: observed.path,
|
||||||
|
});
|
||||||
|
const nextFingerprint = fingerprint(version);
|
||||||
|
if (nextFingerprint === observed.fingerprint) return;
|
||||||
|
observed.fingerprint = nextFingerprint;
|
||||||
|
for (const [listener, identity] of observed.listeners) {
|
||||||
|
listener({ ...version, ...identity });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopWatching(observed: ObservedFile): void {
|
||||||
|
observed.watcher?.close();
|
||||||
|
if (observed.debounce) this.dependencies.clearTimeout(observed.debounce);
|
||||||
|
if (observed.fallback) this.dependencies.clearInterval(observed.fallback);
|
||||||
|
observed.watcher = null;
|
||||||
|
observed.debounce = null;
|
||||||
|
observed.fallback = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprint(version: FileVersion): string {
|
||||||
|
if (version.status !== "ready") return version.status;
|
||||||
|
return `${version.status}:${version.revision ?? `${version.size}:${version.modifiedAt}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workspaceFileObserver = new FileObserver();
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { readExplorerFile } from "./service.js";
|
import { getExplorerFileVersion, readExplorerFile, writeExplorerFile } from "./service.js";
|
||||||
|
|
||||||
async function createHomeTempDir(prefix: string): Promise<string> {
|
async function createHomeTempDir(prefix: string): Promise<string> {
|
||||||
return mkdtemp(path.join(os.homedir(), prefix));
|
return mkdtemp(path.join(os.homedir(), prefix));
|
||||||
@@ -13,6 +13,120 @@ async function createTempDir(prefix: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("file explorer service", () => {
|
describe("file explorer service", () => {
|
||||||
|
it("atomically writes an existing text file at the expected revision", async () => {
|
||||||
|
const root = await createTempDir("paseo-file-write-");
|
||||||
|
try {
|
||||||
|
const filePath = path.join(root, "notes.txt");
|
||||||
|
await writeFile(filePath, "before", "utf8");
|
||||||
|
const current = await getExplorerFileVersion({ root, relativePath: "notes.txt" });
|
||||||
|
expect(current.status).toBe("ready");
|
||||||
|
if (current.status !== "ready") return;
|
||||||
|
|
||||||
|
const result = await writeExplorerFile({
|
||||||
|
root,
|
||||||
|
relativePath: "notes.txt",
|
||||||
|
content: "after",
|
||||||
|
expectedModifiedAt: current.modifiedAt,
|
||||||
|
expectedRevision: current.revision,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("written");
|
||||||
|
expect((await readExplorerFile({ root, relativePath: "notes.txt" })).content).toBe("after");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skipIf(process.platform === "win32")(
|
||||||
|
"preserves the original file permissions across atomic replacement",
|
||||||
|
async () => {
|
||||||
|
const root = await createTempDir("paseo-file-mode-");
|
||||||
|
try {
|
||||||
|
const filePath = path.join(root, "script.sh");
|
||||||
|
await writeFile(filePath, "before", "utf8");
|
||||||
|
await chmod(filePath, 0o764);
|
||||||
|
const current = await getExplorerFileVersion({ root, relativePath: "script.sh" });
|
||||||
|
expect(current.status).toBe("ready");
|
||||||
|
if (current.status !== "ready") return;
|
||||||
|
|
||||||
|
const result = await writeExplorerFile({
|
||||||
|
root,
|
||||||
|
relativePath: "script.sh",
|
||||||
|
content: "after",
|
||||||
|
expectedModifiedAt: current.modifiedAt,
|
||||||
|
expectedRevision: current.revision,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("written");
|
||||||
|
expect((await stat(filePath)).mode & 0o7777).toBe(0o764);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("preserves a newer disk revision instead of overwriting it", async () => {
|
||||||
|
const root = await createTempDir("paseo-file-conflict-");
|
||||||
|
try {
|
||||||
|
const filePath = path.join(root, "notes.txt");
|
||||||
|
await writeFile(filePath, "newer on disk", "utf8");
|
||||||
|
|
||||||
|
const result = await writeExplorerFile({
|
||||||
|
root,
|
||||||
|
relativePath: "notes.txt",
|
||||||
|
content: "stale local edit",
|
||||||
|
expectedModifiedAt: "2020-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: "conflict", version: { status: "ready" } });
|
||||||
|
expect((await readExplorerFile({ root, relativePath: "notes.txt" })).content).toBe(
|
||||||
|
"newer on disk",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the high-precision revision token over the display timestamp", async () => {
|
||||||
|
const root = await createTempDir("paseo-file-revision-");
|
||||||
|
try {
|
||||||
|
const filePath = path.join(root, "notes.txt");
|
||||||
|
await writeFile(filePath, "on disk", "utf8");
|
||||||
|
const current = await getExplorerFileVersion({ root, relativePath: "notes.txt" });
|
||||||
|
expect(current.status).toBe("ready");
|
||||||
|
if (current.status !== "ready") return;
|
||||||
|
|
||||||
|
const result = await writeExplorerFile({
|
||||||
|
root,
|
||||||
|
relativePath: "notes.txt",
|
||||||
|
content: "stale local edit",
|
||||||
|
expectedModifiedAt: current.modifiedAt,
|
||||||
|
expectedRevision: `${current.revision}-stale`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("conflict");
|
||||||
|
expect((await readExplorerFile({ root, relativePath: "notes.txt" })).content).toBe("on disk");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never creates a missing file through the write API", async () => {
|
||||||
|
const root = await createTempDir("paseo-file-missing-");
|
||||||
|
try {
|
||||||
|
const result = await writeExplorerFile({
|
||||||
|
root,
|
||||||
|
relativePath: "missing.txt",
|
||||||
|
content: "new file",
|
||||||
|
expectedModifiedAt: "2020-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: "conflict", version: { status: "missing" } });
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("reads .ex files as text", async () => {
|
it("reads .ex files as text", async () => {
|
||||||
const root = await createTempDir("paseo-file-explorer-");
|
const root = await createTempDir("paseo-file-explorer-");
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { constants, promises as fs } from "fs";
|
import { constants, promises as fs, type BigIntStats } from "fs";
|
||||||
import type { FileHandle } from "fs/promises";
|
import type { FileHandle } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
||||||
|
|
||||||
export type ExplorerEntryKind = "file" | "directory";
|
export type ExplorerEntryKind = "file" | "directory";
|
||||||
@@ -17,6 +18,29 @@ export interface ReadFileParams {
|
|||||||
relativePath: string;
|
relativePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WriteFileParams extends ReadFileParams {
|
||||||
|
content: string;
|
||||||
|
expectedModifiedAt: string;
|
||||||
|
expectedRevision?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExplorerFileVersion =
|
||||||
|
| {
|
||||||
|
status: "ready";
|
||||||
|
cwd: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
modifiedAt: string;
|
||||||
|
revision: string;
|
||||||
|
}
|
||||||
|
| { status: "missing"; cwd: string; path: string }
|
||||||
|
| { status: "error"; cwd: string; path: string; error: string };
|
||||||
|
|
||||||
|
export type ExplorerFileWriteResult =
|
||||||
|
| { status: "written"; modifiedAt: string; size: number; revision: string }
|
||||||
|
| { status: "conflict"; version: ExplorerFileVersion }
|
||||||
|
| { status: "error"; error: string };
|
||||||
|
|
||||||
export interface FileExplorerEntry {
|
export interface FileExplorerEntry {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -38,6 +62,7 @@ export interface FileExplorerFile {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
size: number;
|
size: number;
|
||||||
modifiedAt: string;
|
modifiedAt: string;
|
||||||
|
revision: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileExplorerFileBytes {
|
export interface FileExplorerFileBytes {
|
||||||
@@ -48,6 +73,7 @@ export interface FileExplorerFileBytes {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
modifiedAt: string;
|
modifiedAt: string;
|
||||||
|
revision: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEXT_MIME_TYPES: Record<string, string> = {
|
const TEXT_MIME_TYPES: Record<string, string> = {
|
||||||
@@ -56,10 +82,25 @@ const TEXT_MIME_TYPES: Record<string, string> = {
|
|||||||
|
|
||||||
const DEFAULT_TEXT_MIME_TYPE = "text/plain";
|
const DEFAULT_TEXT_MIME_TYPE = "text/plain";
|
||||||
const FILE_TYPE_SAMPLE_BYTES = 8192;
|
const FILE_TYPE_SAMPLE_BYTES = 8192;
|
||||||
|
export const MAX_EDITABLE_FILE_BYTES = 1024 * 1024;
|
||||||
const READ_FILE_OPEN_FLAGS =
|
const READ_FILE_OPEN_FLAGS =
|
||||||
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
|
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
|
||||||
const ACCESS_OUTSIDE_WORKSPACE_MESSAGE = "Access outside of workspace is not allowed";
|
const ACCESS_OUTSIDE_WORKSPACE_MESSAGE = "Access outside of workspace is not allowed";
|
||||||
|
|
||||||
|
function fileRevision(stats: BigIntStats): string {
|
||||||
|
return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesExpectedRevision(
|
||||||
|
stats: BigIntStats,
|
||||||
|
expectedModifiedAt: string,
|
||||||
|
expectedRevision?: string,
|
||||||
|
): boolean {
|
||||||
|
return expectedRevision
|
||||||
|
? fileRevision(stats) === expectedRevision
|
||||||
|
: stats.mtime.toISOString() === expectedModifiedAt;
|
||||||
|
}
|
||||||
|
|
||||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
".jpg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
@@ -151,6 +192,7 @@ export async function readExplorerFile({
|
|||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
modifiedAt: file.modifiedAt,
|
modifiedAt: file.modifiedAt,
|
||||||
|
revision: file.revision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +204,7 @@ export async function readExplorerFile({
|
|||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
modifiedAt: file.modifiedAt,
|
modifiedAt: file.modifiedAt,
|
||||||
|
revision: file.revision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +216,7 @@ export async function readExplorerFile({
|
|||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
modifiedAt: file.modifiedAt,
|
modifiedAt: file.modifiedAt,
|
||||||
|
revision: file.revision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +228,7 @@ export async function readExplorerFileBytes({
|
|||||||
const handle = await openFileForRead(filePath.resolvedPath);
|
const handle = await openFileForRead(filePath.resolvedPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await handle.stat();
|
const stats = await handle.stat({ bigint: true });
|
||||||
|
|
||||||
if (!stats.isFile()) {
|
if (!stats.isFile()) {
|
||||||
throw new Error("Requested path is not a file");
|
throw new Error("Requested path is not a file");
|
||||||
@@ -193,8 +237,9 @@ export async function readExplorerFileBytes({
|
|||||||
const ext = path.extname(filePath.resolvedPath).toLowerCase();
|
const ext = path.extname(filePath.resolvedPath).toLowerCase();
|
||||||
const basePayload = {
|
const basePayload = {
|
||||||
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
|
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
|
||||||
size: stats.size,
|
size: Number(stats.size),
|
||||||
modifiedAt: stats.mtime.toISOString(),
|
modifiedAt: stats.mtime.toISOString(),
|
||||||
|
revision: fileRevision(stats),
|
||||||
};
|
};
|
||||||
|
|
||||||
const buffer = await handle.readFile();
|
const buffer = await handle.readFile();
|
||||||
@@ -208,7 +253,7 @@ export async function readExplorerFileBytes({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLikelyBinary(buffer)) {
|
if (isLikelyBinary(buffer) || !isValidUtf8(buffer)) {
|
||||||
return {
|
return {
|
||||||
...basePayload,
|
...basePayload,
|
||||||
kind: "binary",
|
kind: "binary",
|
||||||
@@ -230,6 +275,146 @@ export async function readExplorerFileBytes({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getExplorerFileVersion({
|
||||||
|
root,
|
||||||
|
relativePath,
|
||||||
|
}: ReadFileParams): Promise<ExplorerFileVersion> {
|
||||||
|
const cwd = expandUserPath(root);
|
||||||
|
try {
|
||||||
|
const filePath = await resolveScopedPath({ root, relativePath });
|
||||||
|
const stats = await fs.stat(filePath.resolvedPath, { bigint: true });
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return { status: "error", cwd, path: relativePath, error: "Requested path is not a file" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
cwd,
|
||||||
|
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
|
||||||
|
size: Number(stats.size),
|
||||||
|
modifiedAt: stats.mtime.toISOString(),
|
||||||
|
revision: fileRevision(stats),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingEntryError(error)) {
|
||||||
|
return { status: "missing", cwd, path: relativePath };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
cwd,
|
||||||
|
path: relativePath,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveExplorerFilePath({
|
||||||
|
root,
|
||||||
|
relativePath,
|
||||||
|
}: ReadFileParams): Promise<string> {
|
||||||
|
return (await resolveScopedPath({ root, relativePath })).resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeExplorerFile({
|
||||||
|
root,
|
||||||
|
relativePath,
|
||||||
|
content,
|
||||||
|
expectedModifiedAt,
|
||||||
|
expectedRevision,
|
||||||
|
}: WriteFileParams): Promise<ExplorerFileWriteResult> {
|
||||||
|
const encoded = Buffer.from(content, "utf8");
|
||||||
|
if (encoded.byteLength > MAX_EDITABLE_FILE_BYTES) {
|
||||||
|
return { status: "error", error: "File is too large to edit" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let filePath: ScopedPath;
|
||||||
|
let currentMode = 0o600;
|
||||||
|
try {
|
||||||
|
filePath = await resolveScopedPath({ root, relativePath });
|
||||||
|
const handle = await openFileForRead(filePath.resolvedPath);
|
||||||
|
try {
|
||||||
|
const stats = await handle.stat({ bigint: true });
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return { status: "error", error: "Requested path is not a file" };
|
||||||
|
}
|
||||||
|
if (stats.size > BigInt(MAX_EDITABLE_FILE_BYTES)) {
|
||||||
|
return { status: "error", error: "File is too large to edit" };
|
||||||
|
}
|
||||||
|
const current = await handle.readFile();
|
||||||
|
if (isLikelyBinary(current) || !isValidUtf8(current)) {
|
||||||
|
return { status: "error", error: "Binary files cannot be edited" };
|
||||||
|
}
|
||||||
|
currentMode = Number(stats.mode);
|
||||||
|
const modifiedAt = stats.mtime.toISOString();
|
||||||
|
if (!matchesExpectedRevision(stats, expectedModifiedAt, expectedRevision)) {
|
||||||
|
return {
|
||||||
|
status: "conflict",
|
||||||
|
version: {
|
||||||
|
status: "ready",
|
||||||
|
cwd: expandUserPath(root),
|
||||||
|
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
|
||||||
|
size: Number(stats.size),
|
||||||
|
modifiedAt,
|
||||||
|
revision: fileRevision(stats),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingEntryError(error)) {
|
||||||
|
return {
|
||||||
|
status: "conflict",
|
||||||
|
version: { status: "missing", cwd: expandUserPath(root), path: relativePath },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: "error", error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporaryPath = path.join(
|
||||||
|
path.dirname(filePath.resolvedPath),
|
||||||
|
`.${path.basename(filePath.resolvedPath)}.paseo-${randomUUID()}.tmp`,
|
||||||
|
);
|
||||||
|
let temporaryHandle: FileHandle | null = null;
|
||||||
|
try {
|
||||||
|
temporaryHandle = await fs.open(temporaryPath, "wx", currentMode);
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
await temporaryHandle.chmod(currentMode & 0o7777);
|
||||||
|
}
|
||||||
|
await temporaryHandle.writeFile(encoded);
|
||||||
|
await temporaryHandle.sync();
|
||||||
|
await temporaryHandle.close();
|
||||||
|
temporaryHandle = null;
|
||||||
|
const latestStats = await fs.stat(filePath.resolvedPath, { bigint: true });
|
||||||
|
if (!matchesExpectedRevision(latestStats, expectedModifiedAt, expectedRevision)) {
|
||||||
|
return {
|
||||||
|
status: "conflict",
|
||||||
|
version: {
|
||||||
|
status: "ready",
|
||||||
|
cwd: expandUserPath(root),
|
||||||
|
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
|
||||||
|
size: Number(latestStats.size),
|
||||||
|
modifiedAt: latestStats.mtime.toISOString(),
|
||||||
|
revision: fileRevision(latestStats),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await fs.rename(temporaryPath, filePath.resolvedPath);
|
||||||
|
const stats = await fs.stat(filePath.resolvedPath, { bigint: true });
|
||||||
|
return {
|
||||||
|
status: "written",
|
||||||
|
modifiedAt: stats.mtime.toISOString(),
|
||||||
|
size: Number(stats.size),
|
||||||
|
revision: fileRevision(stats),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { status: "error", error: error instanceof Error ? error.message : String(error) };
|
||||||
|
} finally {
|
||||||
|
await temporaryHandle?.close().catch(() => undefined);
|
||||||
|
await fs.unlink(temporaryPath).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDownloadableFileInfo({ root, relativePath }: ReadFileParams): Promise<{
|
export async function getDownloadableFileInfo({ root, relativePath }: ReadFileParams): Promise<{
|
||||||
path: string;
|
path: string;
|
||||||
absolutePath: string;
|
absolutePath: string;
|
||||||
@@ -370,3 +555,12 @@ function isLikelyBinary(buffer: Buffer): boolean {
|
|||||||
|
|
||||||
return suspicious / buffer.length > 0.3;
|
return suspicious / buffer.length > 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidUtf8(buffer: Buffer): boolean {
|
||||||
|
try {
|
||||||
|
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -804,6 +804,7 @@ describe("file explorer binary responses", () => {
|
|||||||
size: 5,
|
size: 5,
|
||||||
encoding: "binary",
|
encoding: "binary",
|
||||||
modifiedAt: expect.any(String),
|
modifiedAt: expect.any(String),
|
||||||
|
revision: expect.any(String),
|
||||||
},
|
},
|
||||||
payload: new Uint8Array(),
|
payload: new Uint8Array(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1962,6 +1962,13 @@ export class Session {
|
|||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "file_explorer_request":
|
case "file_explorer_request":
|
||||||
return this.workspaceFilesSession.handleFileExplorerRequest(msg);
|
return this.workspaceFilesSession.handleFileExplorerRequest(msg);
|
||||||
|
case "fs.file.subscribe.request":
|
||||||
|
return this.workspaceFilesSession.handleFileSubscribeRequest(msg);
|
||||||
|
case "fs.file.unsubscribe.request":
|
||||||
|
this.workspaceFilesSession.handleFileUnsubscribeRequest(msg);
|
||||||
|
return undefined;
|
||||||
|
case "fs.file.write.request":
|
||||||
|
return this.workspaceFilesSession.handleFileWriteRequest(msg);
|
||||||
case "project_icon_request":
|
case "project_icon_request":
|
||||||
return this.workspaceFilesSession.handleProjectIconRequest(msg);
|
return this.workspaceFilesSession.handleProjectIconRequest(msg);
|
||||||
case "file_download_token_request":
|
case "file_download_token_request":
|
||||||
@@ -6321,6 +6328,7 @@ export class Session {
|
|||||||
this.checkoutSession.cleanup();
|
this.checkoutSession.cleanup();
|
||||||
|
|
||||||
this.workspaceGitObserver.dispose();
|
this.workspaceGitObserver.dispose();
|
||||||
|
this.workspaceFilesSession.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type {
|
|||||||
FileDownloadTokenRequest,
|
FileDownloadTokenRequest,
|
||||||
FileExplorerRequest,
|
FileExplorerRequest,
|
||||||
FileUploadRequest,
|
FileUploadRequest,
|
||||||
|
FileSubscribeRequest,
|
||||||
|
FileUnsubscribeRequest,
|
||||||
|
FileWriteRequest,
|
||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
} from "../../messages.js";
|
} from "../../messages.js";
|
||||||
@@ -19,7 +22,9 @@ import {
|
|||||||
listDirectoryEntries,
|
listDirectoryEntries,
|
||||||
readExplorerFile,
|
readExplorerFile,
|
||||||
readExplorerFileBytes,
|
readExplorerFileBytes,
|
||||||
|
writeExplorerFile,
|
||||||
} from "../../file-explorer/service.js";
|
} from "../../file-explorer/service.js";
|
||||||
|
import { workspaceFileObserver, type FileObserver } from "../../file-explorer/observer.js";
|
||||||
import { getProjectIcon } from "../../../utils/project-icon.js";
|
import { getProjectIcon } from "../../../utils/project-icon.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,6 +44,7 @@ export interface WorkspaceFilesSessionOptions {
|
|||||||
downloadTokenStore: DownloadTokenStore;
|
downloadTokenStore: DownloadTokenStore;
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
|
fileObserver?: FileObserver;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,12 +59,81 @@ export class WorkspaceFilesSession {
|
|||||||
private readonly downloadTokenStore: DownloadTokenStore;
|
private readonly downloadTokenStore: DownloadTokenStore;
|
||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
private readonly fileUploads: FileUploadStore;
|
private readonly fileUploads: FileUploadStore;
|
||||||
|
private readonly fileObserver: FileObserver;
|
||||||
|
private readonly fileSubscriptions = new Map<string, () => void>();
|
||||||
|
|
||||||
constructor(options: WorkspaceFilesSessionOptions) {
|
constructor(options: WorkspaceFilesSessionOptions) {
|
||||||
this.host = options.host;
|
this.host = options.host;
|
||||||
this.downloadTokenStore = options.downloadTokenStore;
|
this.downloadTokenStore = options.downloadTokenStore;
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
this.fileUploads = new FileUploadStore({ paseoHome: options.paseoHome });
|
this.fileUploads = new FileUploadStore({ paseoHome: options.paseoHome });
|
||||||
|
this.fileObserver = options.fileObserver ?? workspaceFileObserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleFileSubscribeRequest(request: FileSubscribeRequest): Promise<void> {
|
||||||
|
this.fileSubscriptions.get(request.subscriptionId)?.();
|
||||||
|
try {
|
||||||
|
const subscription = await this.fileObserver.subscribe(
|
||||||
|
{ cwd: request.cwd, path: request.path },
|
||||||
|
(version) => {
|
||||||
|
this.host.emit({
|
||||||
|
type: "fs.file.update",
|
||||||
|
payload: { subscriptionId: request.subscriptionId, version },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.fileSubscriptions.set(request.subscriptionId, subscription.unsubscribe);
|
||||||
|
this.host.emit({
|
||||||
|
type: "fs.file.subscribe.response",
|
||||||
|
payload: {
|
||||||
|
subscriptionId: request.subscriptionId,
|
||||||
|
initial: subscription.initial,
|
||||||
|
requestId: request.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.host.emit({
|
||||||
|
type: "fs.file.subscribe.response",
|
||||||
|
payload: {
|
||||||
|
subscriptionId: request.subscriptionId,
|
||||||
|
initial: {
|
||||||
|
status: "error",
|
||||||
|
cwd: request.cwd,
|
||||||
|
path: request.path,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
},
|
||||||
|
requestId: request.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileUnsubscribeRequest(request: FileUnsubscribeRequest): void {
|
||||||
|
this.fileSubscriptions.get(request.subscriptionId)?.();
|
||||||
|
this.fileSubscriptions.delete(request.subscriptionId);
|
||||||
|
this.host.emit({
|
||||||
|
type: "fs.file.unsubscribe.response",
|
||||||
|
payload: { subscriptionId: request.subscriptionId, requestId: request.requestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleFileWriteRequest(request: FileWriteRequest): Promise<void> {
|
||||||
|
const result = await writeExplorerFile({
|
||||||
|
root: request.cwd,
|
||||||
|
relativePath: request.path,
|
||||||
|
content: request.content,
|
||||||
|
expectedModifiedAt: request.expectedModifiedAt,
|
||||||
|
expectedRevision: request.expectedRevision,
|
||||||
|
});
|
||||||
|
this.host.emit({
|
||||||
|
type: "fs.file.write.response",
|
||||||
|
payload: { result, requestId: request.requestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe();
|
||||||
|
this.fileSubscriptions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> {
|
async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> {
|
||||||
@@ -115,6 +190,7 @@ export class WorkspaceFilesSession {
|
|||||||
size: file.size,
|
size: file.size,
|
||||||
encoding: file.encoding,
|
encoding: file.encoding,
|
||||||
modifiedAt: file.modifiedAt,
|
modifiedAt: file.modifiedAt,
|
||||||
|
revision: file.revision,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1380,6 +1380,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
worktreeRestore: true,
|
worktreeRestore: true,
|
||||||
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
|
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
|
||||||
workspaceRecovery: true,
|
workspaceRecovery: true,
|
||||||
|
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||||
|
workspaceFileEditing: true,
|
||||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||||
providerUsageList: true,
|
providerUsageList: true,
|
||||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||||
|
|||||||
Reference in New Issue
Block a user