Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 62a5eac186 test(e2e): keep developer toasts out of concurrent app assertions
Concurrent layout measurements could invoke the same Playwright locator handler before its first dismissal finished, leaving checks waiting for a removed button. Hide only the Expo toast host through test styling while retaining console error capture.
2026-10-20 08:37:57 +02:00
Mohamed Boudra 398c1a3b52 fix(server): avoid duplicate cwd in worktree provisioning request 2026-10-20 08:01:39 +02:00
Mohamed Boudra c138903a9c refactor(workspaces): own setup exclusively at the workspace boundary
Preserve keyed modern agent worktree creation while retiring legacy placement that selected an agent-owned setup lifecycle. Keep output in per-command workspace snapshots and retain empty strings required by existing wire schemas.
2026-10-20 07:55:24 +02:00
Mohamed Boudra 56f0ad3000 perf(workspaces): stop duplicating setup output in progress snapshots
Keep the required combined log field empty for setup clients, which display per-command output. Preserve concatenation only for legacy agent bootstrap timeline cards whose released renderers still read it directly.
2026-10-20 07:32:31 +02:00
220 changed files with 4843 additions and 11003 deletions
+2
View File
@@ -71,6 +71,8 @@ normal root agent immediately, and closing its still-open tab archives it.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
+9 -27
View File
@@ -240,7 +240,7 @@ Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/client/ui`. Its `Icon` draws a Lucide name from the client's installed
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
@@ -257,14 +257,14 @@ Classify every SDK export before adding it. All client entry points and implemen
Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural
dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts.
| Entry | Owns | May depend on |
| ------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
| `@getpaseo/plugin/client/ui` | Host-provided UI components | Shared and client code |
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
| Entry | Owns | May depend on |
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
Server code imports shared helpers from the root and server capabilities from `/server`. Client
code imports shared helpers from the root and client capabilities from `/client`. Neither runtime
@@ -556,24 +556,6 @@ one section's order from built-ins, plugin groups, and the section's preference
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute file panels
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
panel, and mounts it with a `PluginFile` handle; the panel reads, shows its own `Loading`, writes,
and resolves conflicts. The bar only switches panels and shows the dot the panel sets through
`tab.setModified`. Built-in panels (Markdown, HTML, Image, and Source, Paseo's editor) take the
same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
make that panel faster instead. Source highlights a large file on native in idle-time slices,
the lines around the opening line first.
The handle checks every write against the version it last read or wrote, so two panels or a panel
and an agent cannot silently overwrite each other. The daemon reports the file's kind (text,
image, binary) with its stat from a bounded sniff, which is how Source claims text files without a
read. See the [public file panel reference](../public-docs/plugins/reference.md#file-panels) for
the author API, selection, `DocumentFrame`, and the version requirement. The examples live in
`plugin-examples/pdf-panel` and `plugin-examples/markdown-panel`.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
@@ -56,12 +56,45 @@ async function replaceEditorText(page: Page, content: string): Promise<void> {
await editor.type(content);
}
async function replaceFileAfterDeletionWasObserved(input: {
page: Page;
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
filePath: string;
relativePath: string;
content: string;
}): Promise<void> {
const { page, gate, filePath, relativePath, content } = input;
// Recreate the observed race without faking file state: the daemon publishes the
// deletion, then a real read succeeds after the replacement while its ready event waits.
await gate.waitForFileSubscription(relativePath);
gate.holdFileReads(relativePath);
await unlink(filePath);
await gate.waitForHeldFileRead();
await expect(fileCallout(page)).toHaveCount(0);
gate.releaseHeldFileRead();
await expectOnlyFileCallout(page, "File deleted on disk");
gate.holdNextReadyFileUpdate(relativePath);
await writeFile(filePath, content, "utf8");
await gate.waitForHeldReadyFileUpdate();
await expect.poll(() => readFile(filePath, "utf8")).toBe(content);
}
async function expectOnlyFileCallout(page: Page, title: string): Promise<void> {
await expect(fileCallout(page)).toHaveCount(1);
await expect(fileCallout(page)).toContainText(title);
await expect(filePane(page).getByText(title, { exact: true })).toHaveCount(1);
}
async function expectCleanReplacementCanReload(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "Changed on disk");
await expect(filePane(page).getByText("File deleted on disk", { exact: true })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled();
await fileCallout(page).getByRole("button", { name: "Reload" }).click();
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
await expect(fileCallout(page)).toHaveCount(0);
}
async function expectDeletedFileNotice(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "File deleted on disk");
await expect(fileCallout(page)).toContainText("The open copy is preserved.");
@@ -107,7 +140,7 @@ test.describe("Workspace file change conflicts", () => {
relativePath,
content: "const preserved = true;\n",
});
await expect(visibleEditor(page)).toContainText("const preserved = true;");
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
await restoreFileAfterWatcherObservedTemporaryAbsence({ gate, filePath, relativePath });
@@ -156,79 +189,162 @@ test.describe("Workspace file change conflicts", () => {
relativePath,
content: "const before = true;\n",
});
await expect(visibleEditor(page)).toContainText("const before = true;");
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
gate.holdNextReadyFileUpdate(relativePath);
gate.holdFileReads(relativePath);
await replaceEditorText(page, "const saved = true;\n");
await expect.poll(() => readFile(filePath, "utf8")).toContain("const saved = true;");
await gate.waitForHeldReadyFileUpdate();
gate.releaseHeldReadyFileUpdate();
await gate.waitForHeldFileRead();
await expect(visibleEditor(page)).toContainText("const saved = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
gate.releaseHeldFileRead();
});
test("a viewed file deleted and written back shows the new file", async ({
test("a clean file replaced on disk offers one working reload action", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-clean-replacement-",
relativePath: "inventory.md",
content: "# Before\n",
});
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await unlink(filePath);
await expect(filePane(page).getByText("The file was deleted.", { exact: true })).toBeVisible();
await writeFile(filePath, "# After\n", "utf8");
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
await expect(fileCallout(page)).toHaveCount(0);
await replaceFileAfterDeletionWasObserved({
page,
gate,
filePath,
relativePath: "inventory.md",
content: "# After\n",
});
gate.releaseHeldReadyFileUpdate();
await expectCleanReplacementCanReload(page);
});
test("a deleted file keeps the open copy with one notice and no resolution actions", async ({
test("a deleted file shows one explanatory notice and no resolution actions", async ({
page,
withWorkspace,
}) => {
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-deleted-",
relativePath: "deleted.ts",
content: "const present = true;\n",
relativePath: "deleted.md",
content: "# Present\n",
});
await expect(visibleEditor(page)).toContainText("const present = true;");
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
await recordFileCallouts(page);
await unlink(filePath);
await expectDeletedFileNotice(page);
await expectFileCalloutWasRendered(page, "File deleted on disk");
await expect(visibleEditor(page)).toContainText("const present = true;");
});
test("a path that stops being a file keeps the open copy and follows the file back", async ({
test("a file check error offers a working retry", async ({ page, withWorkspace }) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "retry.md";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-check-retry-",
relativePath,
content: "# Before\n",
});
await gate.waitForFileSubscription(relativePath);
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await unlink(filePath);
await mkdir(filePath);
await expectOnlyFileCallout(page, "Couldn't check file on disk");
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeEnabled();
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await writeFile(filePath, "# After\n", "utf8");
await gate.waitForHeldReadyFileUpdate();
gate.holdFileReads(relativePath);
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
await gate.waitForHeldFileRead();
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeDisabled();
gate.releaseHeldFileRead();
await expectCleanReplacementCanReload(page);
gate.releaseHeldReadyFileUpdate();
});
test("retry clears a transient error when the file is unchanged", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "retry.ts";
const relativePath = "unchanged.md";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-check-error-",
prefix: "file-check-unchanged-",
relativePath,
content: "const before = true;\n",
content: "# Unchanged\n",
});
await expect(visibleEditor(page)).toContainText("const before = true;");
const parkedPath = `${filePath}.parked`;
await gate.waitForFileSubscription(relativePath);
await expect(filePane(page).getByText("Unchanged", { exact: true })).toBeVisible();
await rename(filePath, parkedPath);
await mkdir(filePath);
await expectOnlyFileCallout(page, "Couldn't check file on disk");
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await rename(parkedPath, filePath);
await gate.waitForHeldReadyFileUpdate();
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
await expect(fileCallout(page)).toHaveCount(0);
await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0);
gate.releaseHeldReadyFileUpdate();
});
test("a read-only file error preserves its message and offers retry", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "artifact.bin";
const binaryContent = new Uint8Array([0, 1, 2, 3, 255]);
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-read-only-retry-",
relativePath,
content: binaryContent,
});
await gate.waitForFileSubscription(relativePath);
await expect(
filePane(page).getByText("Binary preview unavailable", { exact: true }),
).toBeVisible();
await unlink(filePath);
await mkdir(filePath);
await gate.waitForFileUpdate(relativePath, "error");
await expect(visibleEditor(page)).toContainText("const before = true;");
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toBeVisible();
await expect(filePane(page).getByRole("button", { name: "Retry" })).toBeEnabled();
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await writeFile(filePath, "const after = true;\n", "utf8");
await writeFile(filePath, binaryContent);
await gate.waitForHeldReadyFileUpdate();
gate.holdFileReads(relativePath);
await filePane(page).getByRole("button", { name: "Retry" }).click();
await gate.waitForHeldFileRead();
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toHaveCount(0);
gate.releaseHeldFileRead();
await expect(visibleEditor(page)).toContainText("const after = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expect(
filePane(page).getByText("Binary preview unavailable", { exact: true }),
).toBeVisible();
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toHaveCount(0);
gate.releaseHeldReadyFileUpdate();
});
test("a changed file with local edits offers overwrite and a working reload", async ({
+20 -18
View File
@@ -55,10 +55,6 @@ async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
await expectFileTabOpen(page, filename);
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
function htmlPreview(page: Page) {
return {
host: page.getByTestId("file-html-preview"),
@@ -66,9 +62,11 @@ function htmlPreview(page: Page) {
};
}
/** Switches the file tab to one of its panels from the bar's switcher. */
async function selectFilePanel(page: Page, title: string): Promise<void> {
const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true });
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
const option = page.getByTestId("file-panel-bar").getByRole("button", {
name: view,
exact: true,
});
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
@@ -130,7 +128,7 @@ test.describe("CodeMirror workspace file editing", () => {
await returnToAssistant(page, session.agentId);
await openAssistantMarkdownLink(page, "Missing capture");
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
await expect(page.getByText(/ENOENT/)).toBeVisible();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
});
} finally {
@@ -319,7 +317,11 @@ test.describe("CodeMirror workspace file editing", () => {
title: "Unsupported large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(path.join(session.cwd, "too-large.txt"), "x".repeat(51 * 1024 * 1024), "utf8");
await writeFile(
path.join(session.cwd, "too-large.txt"),
Buffer.alloc(51 * 1024 * 1024),
"utf8",
);
try {
await openAgentRoute(page, session);
@@ -482,9 +484,9 @@ test.describe("CodeMirror workspace file editing", () => {
relativePath,
);
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
const modeControl = filePanelSwitcher(page);
const modeControl = page.getByTestId("file-preview-mode");
await expect(modeControl).toBeVisible();
await selectFilePanel(page, "Source");
await selectFileView(page, "Source");
const editorHost = page.getByTestId("file-source-editor");
const content = editor(page);
@@ -546,7 +548,7 @@ test.describe("CodeMirror workspace file editing", () => {
);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
await selectFilePanel(page, "Source");
await selectFileView(page, "Source");
const markdownScroller = page
.getByTestId("file-source-editor")
@@ -678,7 +680,7 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
});
test("refreshes Markdown and images while switching between Markdown and Source", async ({
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
page,
withWorkspace,
}) => {
@@ -693,15 +695,15 @@ test.describe("CodeMirror workspace file editing", () => {
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible();
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8");
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
await selectFilePanel(page, "Source");
await selectFileView(page, "Source");
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 selectFilePanel(page, "Markdown");
await selectFileView(page, "Preview");
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
await openWorkspaceFile(page, "pixel.png");
@@ -784,10 +786,10 @@ test.describe("CodeMirror workspace file editing", () => {
);
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
await selectFilePanel(page, "Source");
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(preview.host).toHaveCount(0);
await selectFilePanel(page, "HTML");
await selectFileView(page, "Preview");
await expect(preview.host).toBeVisible();
});
@@ -14,9 +14,8 @@ async function openMarkdownPreview(page: Page) {
await openFileFromExplorer(page, "README.md");
await expectFileTabOpen(page, "README.md");
await page
.getByTestId("file-panel-switcher")
.filter({ visible: true })
.getByRole("button", { name: "Markdown", exact: true })
.getByTestId("file-panel-bar")
.getByRole("button", { name: "Preview", exact: true })
.click();
await ensureWorkspaceAgentPaneVisible(page);
}
@@ -1,272 +0,0 @@
import { execFileSync } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Frame, TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
} from "../support/helpers/file-explorer";
// Full Chromium includes the PDF viewer; the default headless shell does not.
test.use({ channel: "chromium" });
function samplePdf() {
const stream = "BT /F1 24 Tf 72 720 Td (Paseo PDF panel example) Tj ET";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
];
let pdf = "%PDF-1.4\n%\u0000\u00ff\n";
const offsets = objects.map((object, index) => {
const offset = Buffer.byteLength(pdf);
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
return offset;
});
const xref = Buffer.byteLength(pdf);
pdf += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}`;
pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return pdf;
}
async function openFile(page: Page, name: string) {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
const WIDE = { width: 1280, height: 900 };
const COMPACT = { width: 390, height: 844 };
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
/** Captures the current state wide, then compact once `ready` holds there, and returns to wide. */
async function captureWideAndCompact(
page: Page,
testInfo: TestInfo,
name: string,
ready: () => Promise<void>,
) {
await capture(page, testInfo, `${name}-wide`);
await page.setViewportSize(COMPACT);
await ready();
await capture(page, testInfo, `${name}-compact`);
await page.setViewportSize(WIDE);
await ready();
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
/** Turns on vim keybindings without discarding settings the test writes later. */
async function enableVimKeybindings(page: Page) {
await page.addInitScript(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
localStorage.setItem(key, JSON.stringify({ ...settings, vimKeybindings: true }));
});
}
async function chooseRichMarkdown(page: Page) {
await page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }).click();
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
await page.getByRole("button", { name: "Use", exact: true }).click();
}
async function replaceMarkdown(page: Page, text: string) {
await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text);
}
async function selectPanel(page: Page, title: string) {
await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click();
}
async function expectPanels(page: Page, titles: string[]) {
for (const title of titles) {
await expect(
filePanelSwitcher(page).getByRole("button", { name: title, exact: true }),
).toBeInViewport({ ratio: 1 });
}
}
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
async function pdfViewer(page: Page): Promise<Frame> {
const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/;
const find = () =>
page.frames().find((frame) => !frame.isDetached() && viewerUrl.test(frame.url())) ?? null;
await expect.poll(() => find() !== null).toBe(true);
return find()!;
}
async function expectPdfViewerReady(page: Page) {
// A narrow viewer collapses its toolbar to Print and More actions.
const viewer = await pdfViewer(page);
await expect(viewer.getByRole("button", { name: "Print", exact: true })).toBeEnabled();
}
async function expectMarkdownEditor(page: Page, text: string) {
const editor = page.getByRole("textbox", { name: "Rich Markdown editor", exact: true });
await expect(editor).toBeVisible();
await expect(editor).toContainText(text);
await expect(editor).toBeInViewport({ ratio: 1 });
// Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu.
await expect(filePanelSwitcher(page)).toContainText("Rich Markdown");
await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 });
}
test("directory examples mount the PDF frame and write Markdown through the file handle", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const pdf = await copyPluginExample("pdf-panel");
const markdown = await copyPluginExample("markdown-panel");
execFileSync(
process.platform === "win32" ? "npm.cmd" : "npm",
["install", "--ignore-scripts", "--no-audit", "--no-fund"],
{ cwd: markdown.directory, stdio: "pipe" },
);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-examples-",
repo: {
files: [
{
path: "notes.md",
content: "# Markdown panel example\n\nEdit **Markdown** through Paseo.\n",
},
{ path: "second.md", content: "# Remembered panel\n" },
{ path: "report.pdf", content: samplePdf() },
],
},
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(pdf.directory);
await client.installDirectoryPlugin(markdown.directory);
await enableVimKeybindings(page);
await page.setViewportSize(WIDE);
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openFile(page, "report.pdf");
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"src",
/^blob:/,
);
await expect(page.getByTitle("Document preview", { exact: true })).not.toHaveAttribute(
"sandbox",
);
const documentUrl = await page
.getByTitle("Document preview", { exact: true })
.getAttribute("src");
expect(
await page.evaluate(
async (url) => (await fetch(url!)).blob().then((blob) => blob.type),
documentUrl,
),
).toBe("application/pdf");
await expectPdfViewerReady(page);
await expect(
(await pdfViewer(page)).getByRole("textbox", { name: "Page number", exact: true }),
).toHaveValue("1");
await captureWideAndCompact(page, testInfo, "pdf-example", async () => {
await expect(page.getByTitle("Document preview", { exact: true })).toBeInViewport();
await expectPdfViewerReady(page);
});
await openFile(page, "notes.md");
await expect(page.getByRole("radio", { name: "Markdown — Paseo", exact: true })).toBeChecked();
await expect(
page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }),
).toBeVisible();
await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
ratio: 1,
});
});
await chooseRichMarkdown(page);
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
"Markdown panel example",
);
gate.holdNextClientRequest("fs.file.write.request");
await replaceMarkdown(page, "# Edited by Rich Markdown\n\nSaved by the panel.\n");
await gate.waitForHeldClientRequest();
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
await capture(page, testInfo, "markdown-example-dirty-wide");
gate.releaseHeldClientRequest();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]);
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
await expectMarkdownEditor(page, "Edited by Rich Markdown");
});
// Source is Paseo's editor; its cursor and vim mode are in its own status row.
await selectPanel(page, "Source");
const source = page.getByTestId("file-source-editor").filter({ visible: true });
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
await source.locator(".cm-line").nth(2).click();
const bar = page.getByTestId("file-source-status").filter({ visible: true });
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
await capture(page, testInfo, "markdown-example-source-wide");
// The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source.
await page.setViewportSize(COMPACT);
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
await filePanelSwitcher(page).click();
await page.getByRole("menuitem", { name: "Source", exact: true }).click();
await expect(filePanelSwitcher(page)).toHaveText("Source");
await source.locator(".cm-line").nth(2).click();
await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport();
await capture(page, testInfo, "markdown-example-source-compact");
await page.setViewportSize(WIDE);
await selectPanel(page, "Rich Markdown");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText(
"# External update",
);
await replaceMarkdown(page, "# Local edit\n");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Conflicting external edit\n");
await expect(page.getByTestId("rich-markdown-conflict")).toBeVisible();
await capture(page, testInfo, "markdown-example-conflict-wide");
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Local edit\n");
await openFile(page, "second.md");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
"Remembered panel",
);
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await page.setViewportSize(COMPACT);
await expectMarkdownEditor(page, "Remembered panel");
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
} finally {
await client.removePlugin("pdf-panel");
await client.removePlugin("markdown-panel");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close();
await workspace.cleanup();
await pdf.cleanup();
await markdown.cleanup();
}
});
@@ -1,156 +0,0 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import { expectFileTabOpen, openFileExplorer } from "../support/helpers/file-explorer";
import {
BUILTIN_MARKDOWN_OPTION,
MARKDOWN_PANEL_ID,
PLUGIN_MARKDOWN_OPTION,
expectBuiltinMarkdown,
expectPluginMarkdown,
filePanelPicker,
installMarkdownPanelPlugin,
openMarkdown,
storedFilePanelAssociations,
} from "../support/helpers/markdown-file-panel-plugin";
async function choosePanel(page: Page, option: string, options: { remember: boolean }) {
await page.getByRole("radio", { name: option, exact: true }).click();
if (options.remember) {
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
}
await page.getByRole("button", { name: "Use", exact: true }).click();
await expect(filePanelPicker(page)).toBeHidden();
}
async function openWithFromTabMenu(page: Page, name: string, option: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await page.getByRole("menuitem", { name: "Open with…" }).hover();
await page.getByRole("menuitem", { name: option }).click();
}
async function toggleAlwaysUseFromFileBar(page: Page) {
await page.getByTestId("file-panel-bar-menu").filter({ visible: true }).click();
await page.getByRole("menuitem", { name: "Always use for .md" }).click();
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
async function expectSwitcherPanels(page: Page, titles: string[]) {
await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles);
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
test("opens Markdown with the built-in panel and Source beside it without asking", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: "file-panel-builtins-",
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
});
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openMarkdown(page, "notes.md");
await expectBuiltinMarkdown(page, "notes heading");
await expectSwitcherPanels(page, ["Markdown", "Source"]);
await expect(filePanelPicker(page)).toBeHidden();
await capture(page, testInfo, "file-panel-builtins-wide");
await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click();
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click();
await expectBuiltinMarkdown(page, "notes heading");
await page.setViewportSize({ width: 390, height: 844 });
await expectBuiltinMarkdown(page, "notes heading");
await expect(
filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }),
).toBeInViewport({ ratio: 1 });
await expect(filePanelPicker(page)).toBeHidden();
await capture(page, testInfo, "file-panel-builtins-compact");
} finally {
await workspace.cleanup();
}
});
test("asks once which panel opens Markdown and remembers it when told to", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-picker-",
repo: {
files: ["first", "second", "third", "fourth"].map((name) => ({
path: `${name}.md`,
content: `# ${name} heading\n`,
})),
},
});
const plugin = await installMarkdownPanelPlugin();
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
// First open: the built-in renders under the picker.
await openMarkdown(page, "first.md");
await expect(filePanelPicker(page)).toBeVisible();
await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked();
await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked();
await expectBuiltinMarkdown(page, "first heading");
await capture(page, testInfo, "file-panel-picker-wide");
// Don't ask me again: the plugin renders and later opens use it silently.
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: true });
await expectPluginMarkdown(page, "first.md");
expect(await storedFilePanelAssociations(page)).toEqual({ "ext:md": MARKDOWN_PANEL_ID });
await openMarkdown(page, "second.md");
await expectPluginMarkdown(page, "second.md");
await expect(filePanelPicker(page)).toBeHidden();
// Forgetting from the file bar keeps the tab on the plugin.
await toggleAlwaysUseFromFileBar(page);
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
await expectPluginMarkdown(page, "second.md");
// An unremembered choice applies to this tab only; the next open asks again.
await openMarkdown(page, "third.md");
await expect(filePanelPicker(page)).toBeVisible();
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: false });
await expectPluginMarkdown(page, "third.md");
expect(await storedFilePanelAssociations(page)).toEqual({});
// "Open with…" switches the open tab back to the built-in in place.
await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION);
await expectBuiltinMarkdown(page, "third heading");
await expectFileTabOpen(page, "third.md");
// Dismissing keeps the built-in; reopening the tab after a reload asks again.
await openMarkdown(page, "fourth.md");
await expect(filePanelPicker(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(filePanelPicker(page)).toBeHidden();
await expectBuiltinMarkdown(page, "fourth heading");
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
ratio: 1,
});
await capture(page, testInfo, "file-panel-picker-compact");
} finally {
await plugin.uninstall();
await workspace.cleanup();
}
});
@@ -1,144 +0,0 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openSettings } from "../support/helpers/app";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
BUILTIN_MARKDOWN_OPTION,
MARKDOWN_PANEL_ID,
PLUGIN_MARKDOWN_OPTION,
SOURCE_OPTION,
expectPluginMarkdown,
filePanelPicker,
installMarkdownPanelPlugin,
openMarkdown,
storedFilePanelAssociations,
} from "../support/helpers/markdown-file-panel-plugin";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSettingsSection } from "../support/helpers/settings";
const ASK_EVERY_TIME = "Ask every time";
function markdownRow(page: Page) {
return page.getByTestId("file-panel-settings-row-ext:md");
}
function markdownDropdown(page: Page, value: string) {
return markdownRow(page).getByRole("button", { name: `.md: ${value}`, exact: true });
}
function askAgain(page: Page) {
return markdownRow(page).getByRole("button", { name: "Ask again for .md", exact: true });
}
async function openFilesSettings(page: Page) {
await openSettings(page);
await openSettingsSection(page, "files");
await expect(page.getByTestId("file-panel-settings")).toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
async function expectMenuOpen(page: Page) {
await expect(page.getByTestId("file-panel-settings-menu")).toHaveCSS("opacity", "1");
}
test("Settings > Files chooses which panel opens .md and asks again on request", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-settings-",
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
});
const plugin = await installMarkdownPanelPlugin();
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFilesSettings(page);
// Nothing remembered: the row asks every time and has nothing to forget.
await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible({ timeout: 30_000 });
await expect(askAgain(page)).toBeDisabled();
await capture(page, testInfo, "file-panel-settings-wide");
await markdownDropdown(page, ASK_EVERY_TIME).click();
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-wide-menu");
await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click();
await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible();
await expect
.poll(() => storedFilePanelAssociations(page))
.toEqual({
"ext:md": MARKDOWN_PANEL_ID,
});
await capture(page, testInfo, "file-panel-settings-wide-chosen");
// The next open uses the plugin without asking.
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openMarkdown(page, "notes.md");
await expectPluginMarkdown(page, "notes.md");
await expect(filePanelPicker(page)).toBeHidden();
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/settings/files");
await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible({ timeout: 30_000 });
await expect(askAgain(page)).toBeInViewport({ ratio: 1 });
await capture(page, testInfo, "file-panel-settings-compact");
await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-compact-menu");
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
// Ask again forgets the choice; the next open asks.
await askAgain(page).click();
await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible();
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
} finally {
await plugin.uninstall();
await workspace.cleanup();
}
});
test("Settings > Files explains where file panels come from when no plugin adds one", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/settings/files");
const empty = page.getByTestId("file-panel-settings-empty");
await expect(empty).toHaveText("File panels appear here when a plugin adds one", {
timeout: 30_000,
});
await capture(page, testInfo, "file-panel-settings-empty-wide");
await page.setViewportSize({ width: 390, height: 844 });
await expect(empty).toBeVisible();
await capture(page, testInfo, "file-panel-settings-empty-compact");
});
test("Settings > Files lets a panel no host has be forgotten but not chosen", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ filePanelAssociations: { "ext:pdf": "plugin:none/none" } }),
);
});
await page.goto("/settings/files");
const row = page.getByTestId("file-panel-settings-row-ext:pdf");
const dropdown = row.getByRole("button", { name: ".pdf: Not on a connected host" });
await expect(dropdown).toBeDisabled({ timeout: 30_000 });
await row.getByRole("button", { name: "Ask again for .pdf", exact: true }).click();
await expect(page.getByTestId("file-panel-settings-empty")).toBeVisible();
});
@@ -1,232 +0,0 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
} from "../support/helpers/file-explorer";
const PLUGIN_ID = "file-panel-e2e";
const PDF = Buffer.from("%PDF-1.4\n%\x00\xff\n1 0 obj<</Type/Catalog>>endobj\n%%EOF", "binary");
const SOURCE = `import React, { useEffect, useState } from "react";
import { Text, View, Pressable } from "react-native";
import {
PluginFileConflictError,
useFileBytes,
useFileUrl,
usePaseo,
useWorkspace,
} from "@getpaseo/plugin/client";
import { useQueryClient } from "@tanstack/react-query";
import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui";
function Pdf({ file, workspaceId }) {
usePaseo();
useQueryClient();
const workspace = useWorkspace(workspaceId, value => ({ id: value.id }));
const bytes = useFileBytes(file);
const url = useFileUrl(file);
const [urlBytes, setUrlBytes] = useState(-1);
const href = url.status === "ready" ? url.url : null;
useEffect(() => {
if (href) fetch(href).then(response => response.arrayBuffer()).then(body => setUrlBytes(body.byteLength));
}, [href]);
if (bytes.status !== "ready" || !href) return <Loading />;
return <View style={{ flex: 1 }}>
<Text>PDF plugin: {file.name}</Text>
<Text>Path {file.path}</Text>
<Text>Bytes {bytes.bytes.length}; URL bytes {urlBytes}</Text>
<Text>Workspace {workspace?.id}</Text>
<DocumentFrame url={href} testID="plugin-pdf-frame" />
</View>;
}
// Reads once and writes on demand, so a change on disk since that read rejects the write.
function Markdown({ file, tab }) {
const [text, setText] = useState(null);
const [status, setStatus] = useState("");
useEffect(() => { file.readText().then(setText); }, [file]);
const save = (next) => {
tab.setModified(true);
file.writeText(next).then(
() => { tab.setModified(false); setText(next); },
(error) => setStatus(error instanceof PluginFileConflictError ? "Write rejected: changed on disk" : String(error)),
);
};
if (text === null) return <Loading />;
return <View>
<Text>Plugin Markdown: {text}</Text>
<Pressable accessibilityRole="button" onPress={() => save("# Saved by plugin\\n")}>
<Text>Edit Markdown through plugin</Text>
</Pressable>
<Text>{status}</Text>
</View>;
}
export default function contribute(client) {
client.addFilePanel({ id: "pdf", title: "PDF", icon: "FileText",
files: { extensions: ["pdf", "txt"] }, Component: Pdf });
client.addFilePanel({ id: "markdown", title: "Plugin Markdown", icon: "FileText",
files: { extensions: ["md"] }, Component: Markdown });
return () => {};
}`;
async function openWorkspaceFile(page: Page, name: string) {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
async function associateMarkdownWithPlugin(page: Page) {
await page.evaluate(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
settings.filePanelAssociations = { "ext:md": "plugin:file-panel-e2e/markdown" };
localStorage.setItem(key, JSON.stringify(settings));
});
await page.reload();
}
async function editMarkdownThroughPlugin(page: Page) {
await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click();
}
async function selectSource(page: Page) {
await page
.getByTestId("file-panel-switcher")
.filter({ visible: true })
.getByRole("button", { name: "Source", exact: true })
.click();
}
test("directory plugins render PDF bytes and write Markdown through the file handle", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const directory = await mkdtemp(path.join(tmpdir(), "paseo-file-panels-plugin-"));
const workspace = await seedWorkspace({
repoPrefix: "file-panels-",
repo: {
files: [
{ path: "notes.md", content: "# Built-in Markdown\n" },
{ path: "bytes.txt", content: "Bytes panel text document" },
],
},
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(workspace.repoPath, "report.pdf"), PDF);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openWorkspaceFile(page, "report.pdf");
await expect(page.getByText("PDF plugin: report.pdf", { exact: true })).toBeVisible();
await expect(
page.getByText(`Bytes ${PDF.length}; URL bytes ${PDF.length}`, { exact: true }),
).toBeVisible();
await expect(
page.getByText(`Workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"src",
/^blob:/,
);
await expect(
page.getByText(`Path ${path.join(workspace.repoPath, "report.pdf")}`, { exact: true }),
).toBeVisible();
const pdfScreenshot = testInfo.outputPath("plugin-pdf-web.png");
await page.screenshot({ path: pdfScreenshot });
await testInfo.attach("plugin-pdf-web", { path: pdfScreenshot, contentType: "image/png" });
// A plugin panel claiming a text file competes with Source, so Paseo asks over the plugin.
await openWorkspaceFile(page, "bytes.txt");
await expect(page.getByText("Open .txt files with", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Open .txt files with", { exact: true })).toBeHidden();
await expect(page.getByText("PDF plugin: bytes.txt", { exact: true })).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"sandbox",
"",
);
await expect(
page
.frameLocator('[title="Document preview"]')
.getByText("Bytes panel text document", { exact: true }),
).toBeVisible();
await openWorkspaceFile(page, "notes.md");
await expect(page.getByText("Open .md files with", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await expect(
page.getByRole("heading", { name: "Built-in Markdown", exact: true }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Edit Markdown through plugin" })).toHaveCount(0);
await associateMarkdownWithPlugin(page);
await expect(
page.getByText("Plugin Markdown: # Built-in Markdown", { exact: true }),
).toBeVisible();
gate.holdNextClientRequest("fs.file.write.request");
await editMarkdownThroughPlugin(page);
await gate.waitForHeldClientRequest();
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
gate.releaseHeldClientRequest();
await expect(
page.getByText("Plugin Markdown: # Saved by plugin", { exact: true }),
).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Saved by plugin\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
// The disk changed since the plugin's last read or write: the write is rejected, not applied.
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Changed outside\n");
await editMarkdownThroughPlugin(page);
await expect(page.getByText("Write rejected: changed on disk", { exact: true })).toBeVisible();
expect(await readFile(path.join(workspace.repoPath, "notes.md"), "utf8")).toBe(
"# Changed outside\n",
);
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
let closePrompt = "";
page.once("dialog", async (dialog) => {
closePrompt = dialog.message();
await dialog.dismiss();
});
await page
.getByTestId("workspace-tab-file_notes.md")
.filter({ visible: true })
.first()
.click({ button: "right" });
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
await expect.poll(() => closePrompt).toContain("discard");
await selectSource(page);
await expect(
page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"),
).toHaveText("# Changed outside");
const markdownScreenshot = testInfo.outputPath("plugin-markdown-source-web.png");
await page.screenshot({ path: markdownScreenshot });
await testInfo.attach("plugin-markdown-source-web", {
path: markdownScreenshot,
contentType: "image/png",
});
} finally {
await client.removePlugin(PLUGIN_ID);
await client.patchDaemonConfig({
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
});
await client.close();
await workspace.cleanup();
await rm(directory, { recursive: true, force: true });
}
});
@@ -9,7 +9,7 @@ import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspac
const PLUGIN_ID = "plugin-host-ui-e2e";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -1,7 +1,6 @@
import { test } from "../support/fixtures";
import {
withTimelinePlugin,
viewInlineThinkingExample,
requestPluginTimeline,
interactWithStreamingCard,
expectWholeCompletedCard,
@@ -34,9 +33,3 @@ test("Overview preserves both consecutive tool plugin cards", async ({ page }, i
await expectBothConsecutiveTools(page);
});
});
test("inline-thinking example renders a streamed thought through host UI", async ({
page,
}, info) => {
await viewInlineThinkingExample(page, info);
});
@@ -36,7 +36,7 @@ function pluginClientSource(input: {
}): string {
return `import React, { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Icon } from "@getpaseo/plugin/client/ui";
import { Icon } from "@getpaseo/plugin/client/react-native";
import { useAgent, useWorkspace, openExternalUrl } from "@getpaseo/plugin/client";
import { ExternalLink } from "@getpaseo/plugin/client/ui";
import { recordComposerOpen } from "./shared/rpc";
@@ -1,4 +1,5 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
@@ -31,6 +32,28 @@ import {
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
async function showDeveloperToast(page: Page): Promise<void> {
await page.evaluate(() => {
document.getElementById("error-toast")?.remove();
const toast = document.createElement("div");
toast.id = "error-toast";
toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white";
// Expo renders its unnamed dismiss button in an open shadow root.
const button = document.createElement("button");
button.style.cssText = "width:24px;height:24px";
button.addEventListener("click", () => toast.remove());
toast.attachShadow({ mode: "open" }).appendChild(button);
document.body.appendChild(toast);
});
}
test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => {
await gotoAppShell(page);
await showDeveloperToast(page);
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
await expect(page.locator("#error-toast")).toBeHidden();
});
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
@@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
openWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
@@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
(payload) =>
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -18,6 +18,7 @@ import {
} from "../support/helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
@@ -92,7 +93,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -170,16 +172,19 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const initialRunning = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log === "",
(payload) => payload.status === "running" && payload.detail.commands.length === 0,
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
(payload) =>
payload.status === "running" &&
setupCommandOutput(payload.detail).includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
@@ -192,8 +197,8 @@ test.describe("Workspace setup streaming", () => {
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete");
expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete");
expect(completedPayload.error).toBeNull();
} finally {
await client.close();
@@ -215,7 +220,9 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
(payload) =>
payload.status === "failed" &&
setupCommandOutput(payload.detail).includes("setup failed"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
@@ -224,8 +231,8 @@ test.describe("Workspace setup streaming", () => {
});
const failedPayload = await failed;
expect(failedPayload.detail.log).toContain("starting setup");
expect(failedPayload.detail.log).toContain("setup failed");
expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
await openHomeWithProject(page, repo.path);
@@ -331,7 +338,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -377,7 +385,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const result = await client.createPaseoWorktree({
+18 -7
View File
@@ -1,10 +1,21 @@
/** @param {import('@playwright/test').Page} page */
export async function installDeveloperToastDismissal(page) {
// Developer chrome must not cover app actions or move the test's pointer/focus.
export async function hideDeveloperToasts(page) {
// Keep developer chrome out of app interactions without Playwright locator
// handlers: parallel app assertions can race while dismissing the same toast.
// Console errors remain in the runner's logs.
const dismiss = page.locator("#error-toast").getByRole("button", { name: "", exact: true });
await page.addLocatorHandler(dismiss, () => dismiss.dispatchEvent("click"), {
// A new logged error can replace the toast before the old one disappears.
noWaitAfter: true,
});
function hideToasts() {
const installStyle = () => {
const style = document.createElement("style");
style.textContent = "#error-toast { display: none !important; }";
document.head.appendChild(style);
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", installStyle, { once: true });
} else {
installStyle();
}
}
await page.addInitScript(hideToasts);
// Electron connects to an already loaded page; apply there as well.
await page.evaluate(hideToasts);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { test as base, expect, type Page } from "@playwright/test";
import { installDeveloperToastDismissal } from "./developer-ui.mjs";
import { hideDeveloperToasts } from "./developer-ui.mjs";
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
import { startE2EWorker } from "./helpers/e2e-worker";
import { getE2EDaemonPort } from "./helpers/daemon-port";
@@ -131,7 +131,7 @@ const test = daemonTest.extend<{
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await installDeveloperToastDismissal(page);
await hideDeveloperToasts(page);
const entries: string[] = [];
@@ -1,77 +0,0 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { expectFileTabOpen, openFileFromExplorer } from "./file-explorer";
/** A directory plugin whose "Rich Markdown" file panel claims `.md`, next to the built-in. */
export const MARKDOWN_PANEL_PLUGIN_ID = "file-panel-picker-e2e";
export const MARKDOWN_PANEL_ID = `plugin:${MARKDOWN_PANEL_PLUGIN_ID}/rich-markdown`;
export const BUILTIN_MARKDOWN_OPTION = "Markdown — Paseo";
export const PLUGIN_MARKDOWN_OPTION = `Rich Markdown — ${MARKDOWN_PANEL_PLUGIN_ID}`;
export const SOURCE_OPTION = "Source — Paseo";
const SOURCE = `import React from "react";
import { Text, View } from "react-native";
function RichMarkdown({ file }) {
return <View><Text>Rich Markdown plugin: {file.name}</Text></View>;
}
export default function contribute(client) {
client.addFilePanel({ id: "rich-markdown", title: "Rich Markdown", icon: "FileType",
files: { extensions: ["md"] }, Component: RichMarkdown });
return () => {};
}`;
/** Enables plugins on the test daemon and installs the plugin; `uninstall` restores both. */
export async function installMarkdownPanelPlugin(): Promise<{ uninstall(): Promise<void> }> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-markdown-file-panel-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
const uninstall = async () => {
await client.removePlugin(MARKDOWN_PANEL_PLUGIN_ID);
await client.patchDaemonConfig({
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
});
await client.close();
await rm(directory, { recursive: true, force: true });
};
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: MARKDOWN_PANEL_PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
} catch (error) {
await uninstall();
throw error;
}
return { uninstall };
}
export function filePanelPicker(page: Page) {
return page.getByText("Open .md files with", { exact: true });
}
export async function openMarkdown(page: Page, name: string): Promise<void> {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
export async function expectBuiltinMarkdown(page: Page, heading: string): Promise<void> {
await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();
}
export async function expectPluginMarkdown(page: Page, name: string): Promise<void> {
await expect(page.getByText(`Rich Markdown plugin: ${name}`, { exact: true })).toBeVisible();
}
export async function storedFilePanelAssociations(page: Page): Promise<unknown> {
return page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem("@paseo:app-settings") ?? "{}");
return settings.filePanelAssociations ?? {};
});
}
@@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
@@ -274,40 +274,3 @@ export async function withPopoverTimelinePlugin(run: () => Promise<void>): Promi
run,
});
}
const THOUGHT =
"Need to find the scroll container, the layout effect that watches for new messages, and any gesture handler that might fight with programmatic scrolling. Probably a ref on the FlatList plus a near-bottom threshold.";
/** Exercises the maintained inline-thinking renderer against real streamed reasoning. */
export async function viewInlineThinkingExample(page: Page, info: TestInfo): Promise<void> {
info.setTimeout(120_000);
const example = await copyPluginExample("inline-thinking");
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const agent = await seedMockAgentWorkspace({
repoPrefix: "inline-thinking-",
title: "Inline thinking example",
model: "one-minute-stream",
});
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await requestPluginTimeline(agent);
const thought = page.getByText(/^Need to find the scroll container/);
await expect(thought).toBeVisible({ timeout: 60_000 });
await expect(thought).not.toHaveText(THOUGHT);
await info.attach("inline-thinking-streaming", {
body: await page.screenshot({ path: info.outputPath("inline-thinking-streaming.png") }),
contentType: "image/png",
});
await expect(thought).toHaveText(THOUGHT, { timeout: 30_000 });
} finally {
await client.removePlugin("inline-thinking");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close();
await agent.cleanup();
await example.cleanup();
}
}
@@ -24,7 +24,6 @@ const SECTION_LABELS = {
appearance: "Appearance",
sidebar: "Sidebar",
editor: "Editor",
files: "Files",
terminal: "Terminal",
browser: "Browser",
shortcuts: "Shortcuts",
@@ -376,3 +376,7 @@ export function observeSetupPanelTraffic(page: Page) {
},
};
}
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
@@ -1,4 +1,4 @@
import React, { useMemo, type ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import {
View,
Text,
@@ -186,48 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
);
}
interface WorktreeSetupDetailProps {
log: string;
branchName: string;
worktreePath: string;
ds: DetailStyles;
}
function WorktreeSetupDetailSection({
log,
branchName,
worktreePath,
ds,
}: WorktreeSetupDetailProps) {
const setupLog = log.replace(/^\n+/, "");
const hasLog = setupLog.length > 0;
return (
<View style={ds.sectionFillStyle}>
<View style={ds.codeBlockFillStyle}>
<ScrollView
style={ds.codeVerticalScrollStyle}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`}
</Text>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
}
function resolveSubAgentFallbackHeader(
subAgentType: string | null | undefined,
description: string | null | undefined,
@@ -678,17 +636,7 @@ function buildDetailSections(
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
];
}
if (detail.type === "worktree_setup") {
return [
<WorktreeSetupDetailSection
key="worktree-setup"
log={detail.log}
branchName={detail.branchName}
worktreePath={detail.worktreePath}
ds={ds}
/>,
];
}
if (detail.type === "worktree_setup") return [];
if (detail.type === "sub_agent") {
return [
<SubAgentDetailSection
@@ -56,7 +56,10 @@ export type { DraftInput } from "@/stores/draft-store";
export type {
Agent,
ExplorerEntry,
ExplorerFile,
ExplorerEntryKind,
ExplorerFileKind,
ExplorerEncoding,
AgentFileExplorerState,
} from "@/stores/session-store";
@@ -0,0 +1,34 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { describe, expect, it } from "vitest";
import { explorerFileFromReadResult } from "./read-result";
function textRead(bytes: Uint8Array): FileReadResult {
return {
bytes,
mime: "text/plain",
size: bytes.byteLength,
path: "notes.txt",
kind: "text",
modifiedAt: "2026-07-21T00:00:00.000Z",
};
}
describe("explorerFileFromReadResult", () => {
it("records and hides a leading UTF-8 BOM", () => {
const file = explorerFileFromReadResult(
textRead(new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69])),
);
expect(file).toMatchObject({ content: "hi", hasBom: true });
});
it("does not mark BOM-free text or non-leading U+FEFF as BOM files", () => {
const plain = explorerFileFromReadResult(textRead(new TextEncoder().encode("hi")));
const embedded = explorerFileFromReadResult(
textRead(new Uint8Array([0x68, 0x69, 0xef, 0xbb, 0xbf])),
);
expect(plain.hasBom).toBe(false);
expect(embedded.hasBom).toBe(false);
});
});
@@ -0,0 +1,21 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { ExplorerFile } from "@/stores/session-store";
export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
const isText = file.kind === "text";
return {
path: file.path,
kind: file.kind,
encoding: isText ? "utf-8" : "none",
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
hasBom: isText && hasUtf8Bom(file.bytes),
mimeType: file.mime,
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
};
}
function hasUtf8Bom(bytes: Uint8Array): boolean {
return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
}
+129 -25
View File
@@ -1,42 +1,126 @@
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
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 { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import {
FilePanelBarMenu,
FilePanelSwitcher,
useFilePanelChoiceSnapshot,
type FilePanelChoice,
} from "@/file-panels";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { Theme } from "@/styles/theme";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { FileEditorStatus } from "./editor/model";
/**
* The file tab's bar: the switcher between the file's panels, "Open with…", and whether the panel
* has unsaved changes. Shown only when the file has more than one panel; the panel shows the rest.
*/
export function FilePanelBar({ choice, modified }: { choice: FilePanelChoice; modified: boolean }) {
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export function FilePanelBar({
size,
lineCount,
mode,
onModeChange,
editorStatus,
cursor,
vimMode,
conflict,
}: {
size: number;
lineCount?: number;
mode?: "preview" | "source";
onModeChange?(mode: "preview" | "source"): void;
editorStatus?: FileEditorStatus;
cursor?: { line: number; column: number };
vimMode?: string | null;
conflict?: FileConflictAlertState;
}) {
const { t } = useTranslation();
const { openWith } = useFilePanelChoiceSnapshot(choice);
if (!openWith) return null;
const previewModes = [
{
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}>
<PaneContentToolbar testID="file-panel-bar">
<View style={styles.row}>
<View style={styles.spacer} />
{modified ? (
<View
style={styles.modifiedDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
testID="file-panel-modified"
<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}
{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-preview-mode"
options={previewModes}
/>
) : null}
<FilePanelSwitcher choice={choice} />
<FilePanelBarMenu choice={choice} />
</View>
</PaneContentToolbar>
{conflict ? <FileConflictAlert state={conflict} /> : 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,
@@ -48,11 +132,31 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
},
spacer: { flex: 1 },
modifiedDot: {
metadata: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
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.sm,
},
}));
@@ -10,12 +10,14 @@ const warningIconMapping = (theme: Theme) => ({ color: theme.colors.palette.ambe
export type FileConflictAlertState =
| { kind: "changed"; canOverwrite: boolean; onReload(): void; onOverwrite(): void }
| { kind: "deleted" };
| { kind: "deleted" }
| { kind: "checkFailed"; retrying: boolean; onRetry(): void };
export function FileConflictAlert({ state }: { state: FileConflictAlertState }) {
const { t } = useTranslation();
let title = t("panels.file.editor.changedOnDisk");
if (state.kind === "deleted") title = t("panels.file.editor.deletedTitle");
else if (state.kind === "checkFailed") title = t("panels.file.editor.checkFailedTitle");
let description: string | undefined;
if (state.kind !== "changed") description = t("panels.file.editor.preservedDescription");
else if (state.canOverwrite) description = t("panels.file.editor.conflictDescription");
@@ -27,16 +29,23 @@ export function FileConflictAlert({ state }: { state: FileConflictAlertState })
<Text style={styles.title}>{title}</Text>
{description ? <Text style={styles.description}>{description}</Text> : null}
</View>
{state.kind === "changed" ? (
{state.kind !== "deleted" ? (
<View style={styles.actions}>
{state.canOverwrite ? (
{state.kind === "changed" && state.canOverwrite ? (
<Button variant="outline" size="sm" onPress={state.onOverwrite}>
{t("panels.file.editor.overwrite")}
</Button>
) : null}
<Button variant="outline" size="sm" onPress={state.onReload}>
{t("panels.file.editor.reload")}
</Button>
{state.kind === "changed" ? (
<Button variant="outline" size="sm" onPress={state.onReload}>
{t("panels.file.editor.reload")}
</Button>
) : null}
{state.kind === "checkFailed" ? (
<Button variant="outline" size="sm" onPress={state.onRetry} loading={state.retrying}>
{t("common.actions.retry")}
</Button>
) : null}
</View>
) : null}
</View>
@@ -0,0 +1,652 @@
import { describe, expect, test } from "vitest";
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
import {
FileEditorModel,
getFileConflictCallout,
type FileEditorClock,
type FileEditorFile,
type FileEditorObservation,
type FileEditorSession,
type FileObservationSource,
} 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 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;
}
}
class ObservationSource implements FileObservationSource {
observation: FileEditorObservation | null;
refreshes = 0;
private readonly listeners = new Set<() => void>();
constructor(observation: FileEditorObservation | null) {
this.observation = observation;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
getObservation(): FileEditorObservation | null {
return this.observation;
}
refresh = (): void => {
this.refreshes += 1;
};
emit(observation: FileEditorObservation): void {
this.observation = observation;
for (const listener of this.listeners) listener();
}
}
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 };
}
interface MakeModelInput {
content?: string;
hasBom?: boolean;
}
function makeModel(input: MakeModelInput = {}) {
const file = {
content: input.content ?? "one",
hasBom: input.hasBom ?? false,
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 };
}
function observeFile(model: FileEditorModel, file: FileEditorFile): void {
model.receiveFileObservation({ status: "ready", file });
}
function observeVersion(model: FileEditorModel, version: FileEditorObservation): void {
model.receiveFileObservation(version);
}
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();
observeFile(model, {
content: "one",
hasBom: false,
version: { ...ready(), revision: "precise-revision" },
});
expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" });
});
test("adopts an unchanged disk revision without disturbing a dirty buffer", async () => {
const { model, session } = makeModel();
model.edit("local");
observeFile(model, {
content: "one",
hasBom: false,
version: { ...ready(), revision: "replacement-revision" },
});
expect(model.getSnapshot()).toMatchObject({ status: "dirty", content: "local" });
await model.save();
expect(session.writes).toEqual([
{
content: "local",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
expectedRevision: "replacement-revision",
},
]);
});
test("adopts an external BOM change before saving a dirty buffer", async () => {
const { model, session } = makeModel();
model.edit("local");
observeFile(model, {
content: "one",
hasBom: true,
version: { ...ready(), revision: "replacement-revision" },
});
await model.save();
expect(session.writes).toEqual([
{
content: "\uFEFFlocal",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
expectedRevision: "replacement-revision",
},
]);
});
test("conflicts when a same-content observation changes the BOM during a save", async () => {
const { model, session } = makeModel();
session.holdNextWrite();
model.edit("saved");
const save = model.save();
observeFile(model, {
content: "saved",
hasBom: true,
version: ready("2026-07-18T00:00:02.000Z", 6),
});
session.finishHeldWrite({
status: "written",
modifiedAt: "2026-07-18T00:00:01.000Z",
size: 5,
});
await save;
expect(model.getSnapshot()).toMatchObject({
status: "conflict",
observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" },
});
});
test("ignores a settled observation that was already consumed", () => {
const { model } = makeModel();
const observation: FileEditorObservation = {
status: "ready",
file: { content: "one", hasBom: false, version: ready() },
};
let emissions = 0;
model.subscribe(() => {
emissions += 1;
});
model.receiveFileObservation(observation);
const emissionsAfterFirstDelivery = emissions;
model.receiveFileObservation(observation);
expect(emissions).toBe(emissionsAfterFirstDelivery);
});
test("does not replay the pre-save observation while its refresh is pending", async () => {
const { model } = makeModel();
const observation: FileEditorObservation = {
status: "ready",
file: { content: "one", hasBom: false, version: ready() },
};
model.receiveFileObservation(observation);
model.edit("saved");
await model.save();
model.receiveFileObservation(observation);
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "saved" });
});
test("does not reload stale bytes after a write conflict", async () => {
const { model, session } = makeModel();
session.nextWrite = {
status: "conflict",
version: ready("2026-07-18T00:00:02.000Z", 8),
};
model.edit("important local work");
await model.save();
await model.reload();
expect(model.getSnapshot()).toMatchObject({
status: "conflict",
content: "important local work",
observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" },
});
});
test("reloads a write conflict only after refreshed bytes arrive", async () => {
const { model, session } = makeModel();
const source = new ObservationSource({
status: "ready",
file: { content: "one", hasBom: false, version: ready() },
});
model.connectFileObservations(source);
session.nextWrite = {
status: "conflict",
version: ready("2026-07-18T00:00:02.000Z", 4),
};
model.edit("local");
await model.save();
await model.reload();
expect(source.refreshes).toBe(1);
expect(model.getSnapshot().content).toBe("local");
source.emit({
status: "ready",
file: {
content: "disk",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 4),
},
});
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
});
test("abandons a deferred reload when its refresh fails", async () => {
const { model, session } = makeModel();
const source = new ObservationSource({
status: "ready",
file: { content: "one", hasBom: false, version: ready() },
});
model.connectFileObservations(source);
session.nextWrite = {
status: "conflict",
version: ready("2026-07-18T00:00:02.000Z", 4),
};
model.edit("local");
await model.save();
await model.reload();
source.emit({
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "File unavailable.",
});
model.edit("new local work");
source.emit({
status: "ready",
file: {
content: "disk",
hasBom: false,
version: ready("2026-07-18T00:00:03.000Z", 4),
},
});
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "new local work" });
});
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("keeps CRLF content in file form", async () => {
const { model, session } = makeModel({ content: "one\r\ntwo\r\n" });
expect(model.getSnapshot()).toMatchObject({
content: "one\r\ntwo\r\n",
lineSeparator: "\r\n",
});
model.edit("one\r\ntwo\r\nthree\r\n");
await model.save();
expect(session.writes).toEqual([
{
content: "one\r\ntwo\r\nthree\r\n",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
},
]);
});
test("restores a UTF-8 BOM before writing a CRLF file", async () => {
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
model.edit("saved\r\n");
await model.save();
model.edit("saved again\r\n");
await model.save();
expect(session.writes).toEqual([
{
content: "\uFEFFsaved\r\n",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
},
{
content: "\uFEFFsaved again\r\n",
expectedModifiedAt: "2026-07-18T00:00:01.000Z",
},
]);
});
test("uses the first line separator when a file mixes styles", () => {
const { model } = makeModel({ content: "one\r\ntwo\nthree\r" });
expect(model.getSnapshot().lineSeparator).toBe("\r\n");
});
test("reloads a clean editor when the disk version changes", async () => {
const { model, session } = makeModel();
session.file = {
content: "external",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
};
observeFile(model, session.file);
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" });
});
test("adopts the format from a clean remote refresh", async () => {
const { model, session } = makeModel({ content: "local\r\n", hasBom: true });
session.file = {
content: "remote\n",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 7) as Extract<FileVersion, { status: "ready" }>,
};
observeFile(model, session.file);
expect(model.getSnapshot().lineSeparator).toBe("\n");
model.edit("saved\n");
await model.save();
expect(session.writes.at(-1)).toEqual({
content: "saved\n",
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
});
});
test("applies consecutive clean disk observations", () => {
const { model } = makeModel();
const firstVersion = ready("2026-07-18T00:00:02.000Z", 5);
const latestVersion = ready("2026-07-18T00:00:03.000Z", 6);
observeFile(model, { content: "first", hasBom: false, version: firstVersion });
observeFile(model, { content: "latest", hasBom: false, version: latestVersion });
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");
observeFile(model, {
content: "disk",
hasBom: false,
version: 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("keeps the local CRLF and BOM when overwriting a conflict", async () => {
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
model.edit("local\r\n");
observeFile(model, {
content: "disk",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 4),
});
await model.overwrite();
expect(session.writes).toEqual([
{
content: "\uFEFFlocal\r\n",
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
},
]);
});
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", hasBom: false, version: diskVersion };
observeFile(model, session.file);
await model.reload();
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
});
test("adopts the remote format when reloading a conflict", async () => {
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
model.edit("local\r\n");
const diskVersion = ready("2026-07-18T00:00:02.000Z", 5) as Extract<
FileVersion,
{ status: "ready" }
>;
session.file = { content: "disk\n", hasBom: false, version: diskVersion };
observeFile(model, session.file);
await model.reload();
model.edit("saved\n");
await model.save();
expect(session.writes.at(-1)).toEqual({
content: "saved\n",
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
});
});
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");
observeVersion(model, { status: "missing", cwd: "/workspace", path: "file.ts" });
clock.fire();
expect(model.getSnapshot().status).toBe("conflict");
expect(session.writes).toEqual([]);
});
test("clears a transient check error when the recovered file is unchanged", () => {
const { model } = makeModel();
observeVersion(model, {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
});
observeFile(model, { content: "one", hasBom: false, version: ready() });
expect(model.getSnapshot()).toMatchObject({
status: "clean",
modified: false,
observedVersion: { status: "ready" },
error: null,
});
});
test("resumes autosave when a dirty file recovers unchanged from a check error", async () => {
const { model, session, clock } = makeModel();
model.edit("local");
observeVersion(model, {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
});
observeFile(model, { content: "one", hasBom: false, version: ready() });
clock.fire();
await Promise.resolve();
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
expect(session.writes).toEqual([
{ content: "local", expectedModifiedAt: "2026-07-18T00:00:00.000Z" },
]);
});
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);
});
test("maps conflict versions to one exhaustive callout state", () => {
const { model } = makeModel();
const snapshot = model.getSnapshot();
expect(getFileConflictCallout(snapshot)).toBeNull();
expect(
getFileConflictCallout({
...snapshot,
status: "conflict",
modified: true,
observedVersion: ready("2026-07-18T00:00:01.000Z"),
}),
).toEqual({ kind: "changed", canOverwrite: true });
expect(
getFileConflictCallout({
...snapshot,
status: "conflict",
observedVersion: { status: "missing", cwd: "/workspace", path: "file.ts" },
}),
).toEqual({ kind: "deleted" });
expect(
getFileConflictCallout({
...snapshot,
status: "conflict",
observedVersion: {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "unreadable",
},
}),
).toEqual({ kind: "checkFailed" });
});
});
+402
View File
@@ -0,0 +1,402 @@
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
export type FileEditorStatus = "clean" | "dirty" | "saving" | "conflict" | "error";
export type FileLineSeparator = "\n" | "\r\n" | "\r";
export interface FileEditorSnapshot {
status: FileEditorStatus;
content: string;
lineSeparator: FileLineSeparator;
modified: boolean;
version: FileVersion;
observedVersion: FileVersion;
error: string | null;
}
export type FileConflictCallout =
| { kind: "changed"; canOverwrite: boolean }
| { kind: "deleted" }
| { kind: "checkFailed" };
export interface FileEditorFile {
content: string;
hasBom: boolean;
version: Extract<FileVersion, { status: "ready" }>;
}
export interface FileEditorSession {
write(input: {
content: string;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult>;
}
export type FileEditorObservation =
| { status: "ready"; file: FileEditorFile }
| Extract<FileVersion, { status: "missing" | "error" }>;
export interface FileObservationSource {
subscribe(listener: () => void): () => void;
getObservation(): FileEditorObservation | null;
refresh(): void;
}
type ObservedDiskState = FileEditorObservation | { status: "unsettled"; version: FileVersion };
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: FileEditorObservation | null = null;
private observed: ObservedDiskState;
private lastReceivedObservation: FileEditorObservation | null = null;
private refreshObservation: (() => void) | null = null;
private reloadRequested = false;
private persistedContent: string;
private hasBom: boolean;
private unsubscribeObservationSource: (() => void) | null = null;
constructor(input: {
file: FileEditorFile;
session: FileEditorSession;
clock?: FileEditorClock;
}) {
this.session = input.session;
this.clock = input.clock ?? systemClock;
this.persistedContent = input.file.content;
this.hasBom = input.file.hasBom;
this.observed = { status: "ready", file: input.file };
this.snapshot = {
status: "clean",
content: input.file.content,
lineSeparator: detectLineSeparator(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;
connectFileObservations(source: FileObservationSource): void {
this.disconnectFileObservations();
this.refreshObservation = source.refresh;
const receiveObservation = () => {
const observation = source.getObservation();
if (observation) this.receiveFileObservation(observation);
};
this.unsubscribeObservationSource = source.subscribe(receiveObservation);
receiveObservation();
}
disconnectFileObservations(): void {
this.unsubscribeObservationSource?.();
this.unsubscribeObservationSource = null;
this.refreshObservation = null;
}
edit(content: string): void {
if (this.disposed || content === this.snapshot.content) return;
this.reloadRequested = false;
const modified = content !== this.persistedContent;
let status: FileEditorStatus = modified ? "dirty" : "clean";
if (this.snapshot.status === "conflict") {
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);
}
receiveFileObservation(observation: FileEditorObservation): void {
if (this.disposed || observation === this.lastReceivedObservation) return;
this.lastReceivedObservation = observation;
const version = observationVersion(observation);
this.observed = observation;
this.setSnapshot({ ...this.snapshot, observedVersion: version });
if (this.snapshot.status === "saving") {
this.observedWhileSaving = observation;
return;
}
if (observation.status !== "ready") {
this.reloadRequested = false;
this.enterConflict(version);
return;
}
if (this.reloadRequested) {
this.reloadRequested = false;
this.applyFile(observation.file);
return;
}
if (observation.file.content === this.persistedContent) {
this.adoptUnchangedFile(observation.file);
return;
}
if (this.snapshot.status === "clean") {
this.applyFile(observation.file);
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;
if (this.observed.status !== "ready") {
this.reloadRequested = true;
this.refreshObservation?.();
return;
}
this.applyFile(this.observed.file);
}
dispose(): void {
this.disposed = true;
this.reloadRequested = false;
this.saveSequence += 1;
this.clearAutosave();
this.disconnectFileObservations();
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;
const hasBom = this.hasBom;
this.observedWhileSaving = null;
this.setSnapshot({ ...this.snapshot, status: "saving", error: null });
const serializedContent = hasBom ? `\uFEFF${content}` : content;
let result: FileWriteResult;
try {
result = await this.session.write({
content: serializedContent,
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.observed = { status: "unsettled", version: result.version };
this.enterConflict(result.version);
return;
}
const writtenVersion: Extract<FileVersion, { status: "ready" }> = {
status: "ready",
cwd: this.snapshot.version.cwd,
path: this.snapshot.version.path,
size: result.size,
modifiedAt: result.modifiedAt,
revision: result.revision,
};
const pending = this.takeObservedWhileSaving();
this.persistedContent = content;
if (pending && !observationMatchesWrite(pending, content, hasBom)) {
const pendingVersion = observationVersion(pending);
this.observed = pending;
this.setSnapshot({
...this.snapshot,
status: "conflict",
modified: this.snapshot.content !== this.persistedContent,
version: writtenVersion,
observedVersion: pendingVersion,
error: null,
});
return;
}
const settledVersion = pending?.status === "ready" ? pending.file.version : writtenVersion;
this.observed = pending ?? { status: "unsettled", version: writtenVersion };
const modified = this.snapshot.content !== this.persistedContent;
this.setSnapshot({
...this.snapshot,
status: modified ? "dirty" : "clean",
modified,
version: settledVersion,
observedVersion: settledVersion,
error: null,
});
if (modified) this.scheduleAutosave();
}
private applyFile(file: FileEditorFile): void {
this.clearAutosave();
this.saveSequence += 1;
this.persistedContent = file.content;
this.hasBom = file.hasBom;
this.observed = { status: "ready", file };
this.setSnapshot({
status: "clean",
content: file.content,
lineSeparator: detectLineSeparator(file.content),
modified: false,
version: file.version,
observedVersion: file.version,
error: null,
});
}
private takeObservedWhileSaving(): FileEditorObservation | null {
const observation = this.observedWhileSaving;
this.observedWhileSaving = null;
return observation;
}
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 adoptUnchangedFile(file: FileEditorFile): void {
this.hasBom = file.hasBom;
this.observed = { status: "ready", file };
const modified = this.snapshot.content !== this.persistedContent;
const recovering = this.snapshot.status === "conflict";
let status = this.snapshot.status;
if (recovering) status = modified ? "dirty" : "clean";
this.setSnapshot({
...this.snapshot,
status,
modified,
version: file.version,
observedVersion: file.version,
error: recovering ? null : this.snapshot.error,
});
if (status === "dirty") this.scheduleAutosave();
else this.clearAutosave();
}
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();
}
}
export function getFileConflictCallout(snapshot: FileEditorSnapshot): FileConflictCallout | null {
if (snapshot.status !== "conflict") return null;
switch (snapshot.observedVersion.status) {
case "ready":
return { kind: "changed", canOverwrite: snapshot.modified };
case "missing":
return { kind: "deleted" };
case "error":
return { kind: "checkFailed" };
default:
return assertNever(snapshot.observedVersion);
}
}
function assertNever(value: never): never {
throw new Error(`Unexpected file version: ${JSON.stringify(value)}`);
}
function detectLineSeparator(content: string): FileLineSeparator {
for (let index = 0; index < content.length; index += 1) {
const character = content.charCodeAt(index);
if (character === 10) return "\n";
if (character === 13) return content.charCodeAt(index + 1) === 10 ? "\r\n" : "\r";
}
return "\n";
}
function observationVersion(observation: FileEditorObservation): FileVersion {
return observation.status === "ready" ? observation.file.version : observation;
}
function observationMatchesWrite(
observation: FileEditorObservation,
content: string,
hasBom: boolean,
): boolean {
return (
observation.status === "ready" &&
observation.file.content === content &&
observation.file.hasBom === hasBom
);
}
@@ -0,0 +1,60 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { describe, expect, test } from "vitest";
import type { LiveFileObservation } from "../live-file/model";
import { createFileObservationSource, type LiveObservationSource } from "./observation-source";
class TestLiveObservationSource implements LiveObservationSource {
observation: LiveFileObservation | null;
constructor(observation: LiveFileObservation | null) {
this.observation = observation;
}
subscribe(): () => void {
return () => undefined;
}
getObservation(): LiveFileObservation | null {
return this.observation;
}
refresh(): void {}
}
function readyObservation(): LiveFileObservation {
const file: FileReadResult = {
bytes: new TextEncoder().encode("one"),
mime: "text/plain",
size: 3,
path: "file.ts",
kind: "text",
modifiedAt: "2026-07-18T00:00:00.000Z",
revision: "revision-1",
};
return {
status: "ready",
file,
version: {
status: "ready",
cwd: "/workspace",
path: "file.ts",
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
},
};
}
describe("createFileObservationSource", () => {
test("returns the same editor observation until the live observation changes", () => {
const liveFile = new TestLiveObservationSource(readyObservation());
const source = createFileObservationSource(liveFile);
const first = source.getObservation();
const repeated = source.getObservation();
expect(repeated).toBe(first);
liveFile.observation = readyObservation();
expect(source.getObservation()).not.toBe(first);
});
});
@@ -0,0 +1,50 @@
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { LiveFileObservation } from "../live-file/model";
import type { FileEditorObservation, FileObservationSource } from "./model";
export interface LiveObservationSource {
subscribe(listener: () => void): () => void;
getObservation(): LiveFileObservation | null;
refresh(): void;
}
export function createFileObservationSource(
liveFile: LiveObservationSource,
): FileObservationSource {
let liveObservation: LiveFileObservation | null = null;
let editorObservation: FileEditorObservation | null = null;
return {
subscribe: liveFile.subscribe,
refresh: liveFile.refresh,
getObservation() {
const nextLiveObservation = liveFile.getObservation();
if (nextLiveObservation === liveObservation) return editorObservation;
liveObservation = nextLiveObservation;
editorObservation = fileEditorObservation(nextLiveObservation);
return editorObservation;
},
};
}
function fileEditorObservation(
observation: LiveFileObservation | null,
): FileEditorObservation | null {
if (!observation || observation.status !== "ready") return observation;
const decodedFile = explorerFileFromReadResult(observation.file);
if (decodedFile.kind !== "text" || decodedFile.content === undefined) {
return {
status: "error",
cwd: observation.version.cwd,
path: observation.version.path,
error: "File is no longer text.",
};
}
return {
status: "ready",
file: {
content: decodedFile.content,
hasBom: decodedFile.hasBom,
version: observation.version,
},
};
}
@@ -22,7 +22,6 @@ export function FileEditorView(_props: {
};
onCursorChange(position: { line: number; column: number }): void;
onVimModeChange(mode: string | null): void;
onReady(): void;
}) {
return (
<View style={styles.container}>
@@ -4,6 +4,7 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import { isRenderedMarkdownFile } from "@/file-panels";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
@@ -17,8 +18,6 @@ interface FileEditorViewProps {
theme: EditorVisualTheme;
onCursorChange(position: { line: number; column: number }): void;
onVimModeChange(mode: string | null): void;
/** The editor shows the file's content. */
onReady(): void;
}
const languageCompartment = new Compartment();
@@ -26,12 +25,8 @@ const wrappingCompartment = new Compartment();
const themeCompartment = new Compartment();
const vimCompartment = new Compartment();
/** Markdown is prose, so its paragraphs wrap; code keeps its lines. */
const WRAPPED_EXTENSIONS = new Set(["md", "markdown"]);
function wrappingForFile(filename: string) {
const extension = filename.split(".").pop()?.toLowerCase() ?? "";
return WRAPPED_EXTENSIONS.has(extension) ? EditorView.lineWrapping : [];
return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : [];
}
export function FileEditorView({
@@ -43,20 +38,12 @@ export function FileEditorView({
theme,
onCursorChange,
onVimModeChange,
onReady,
}: FileEditorViewProps) {
const [find] = useState(() => new FileFindModel());
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const initial = useRef({
filename,
model,
theme,
vimEnabled,
content: snapshot.content,
onReady,
});
const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content });
const onCursorChangeRef = useRef(onCursorChange);
onCursorChangeRef.current = onCursorChange;
@@ -93,7 +80,6 @@ export function FileEditorView({
});
viewRef.current = view;
onCursorChangeRef.current({ line: 1, column: 1 });
values.onReady();
return () => {
view.destroy();
viewRef.current = null;
@@ -0,0 +1,61 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { LiveFileModel, type LiveFileSession } from "./model";
import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation";
import { isWeb } from "@/constants/platform";
export function useLiveFile(input: {
client: DaemonClient | null;
cwd: string | null;
path: string | null;
enabled: boolean;
liveUpdates: boolean;
}) {
const [model] = useState(() => new LiveFileModel());
const session = useMemo<LiveFileSession | null>(() => {
if (!input.client) return null;
const client = input.client;
return {
subscribe(target, onVersion) {
return client.subscribeFile(target, onVersion);
},
read(target) {
return client.readFile(
target.cwd,
target.path,
undefined,
SOURCE_PRESENTATION_BUDGETS[isWeb ? "web" : "native"].plain,
);
},
};
}, [input.client]);
useEffect(() => {
if (!input.enabled || !session || !input.cwd || !input.path) {
model.close();
return;
}
model.open({
session,
target: { cwd: input.cwd, path: input.path },
liveUpdates: input.liveUpdates,
});
return () => model.close();
}, [input.cwd, input.enabled, input.liveUpdates, input.path, model, session]);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
return {
file: snapshot.observation?.status === "ready" ? snapshot.observation.file : null,
error: snapshot.read.status === "error" ? snapshot.read.error : null,
// Effects start reads after the first render. An enabled target is therefore
// already in the producer's initial/retargeting display state.
isFetching:
input.enabled &&
(snapshot.read.status === "pending" ||
(snapshot.read.status === "idle" && snapshot.observation === null)),
isRetrying: snapshot.read.status === "pending" && snapshot.read.requested,
refresh: model.refresh,
model,
snapshot,
};
}
@@ -0,0 +1,344 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
import { describe, expect, it, vi } from "vitest";
import { LiveFileModel, type LiveFileSession, type LiveFileSubscription } from "./model";
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: Error): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
function ready(revision = "revision-1"): Extract<FileVersion, { status: "ready" }> {
return {
status: "ready",
cwd: "/workspace",
path: "file.ts",
size: 3,
modifiedAt: "2026-07-30T00:00:00.000Z",
revision,
};
}
function file(revision = "revision-1", content = "one"): FileReadResult {
return {
bytes: new TextEncoder().encode(content),
mime: "text/plain",
size: content.length,
path: "file.ts",
kind: "text",
modifiedAt: "2026-07-30T00:00:00.000Z",
revision,
};
}
class TestLiveFileSession implements LiveFileSession {
readonly reads: Deferred<FileReadResult>[] = [];
subscribeAttempts = 0;
private onVersion: ((version: FileVersion) => void) | null = null;
private readonly initial: FileVersion;
constructor(initial: FileVersion = ready()) {
this.initial = initial;
}
async subscribe(
_target: { cwd: string; path: string },
onVersion: (version: FileVersion) => void,
): Promise<LiveFileSubscription> {
this.subscribeAttempts += 1;
this.onVersion = onVersion;
return { initial: this.initial, unsubscribe: () => (this.onVersion = null) };
}
read(): Promise<FileReadResult> {
const request = deferred<FileReadResult>();
this.reads.push(request);
return request.promise;
}
emit(version: FileVersion): void {
this.onVersion?.(version);
}
}
class DeferredSubscribeSession implements LiveFileSession {
readonly reads: Deferred<FileReadResult>[] = [];
readonly subscription = deferred<LiveFileSubscription>();
private onVersion: ((version: FileVersion) => void) | null = null;
subscribe(
_target: { cwd: string; path: string },
onVersion: (version: FileVersion) => void,
): Promise<LiveFileSubscription> {
this.onVersion = onVersion;
return this.subscription.promise;
}
read(): Promise<FileReadResult> {
const request = deferred<FileReadResult>();
this.reads.push(request);
return request.promise;
}
emit(version: FileVersion): void {
this.onVersion?.(version);
}
}
class FlakySubscriptionSession extends TestLiveFileSession {
override async subscribe(
target: { cwd: string; path: string },
onVersion: (version: FileVersion) => void,
): Promise<LiveFileSubscription> {
if (this.subscribeAttempts === 0) {
this.subscribeAttempts += 1;
throw new Error("Connection closed.");
}
return super.subscribe(target, onVersion);
}
}
async function waitForRead(
session: { reads: Deferred<FileReadResult>[] },
count: number,
): Promise<void> {
await vi.waitFor(() => expect(session.reads).toHaveLength(count));
}
describe("LiveFileModel", () => {
it("reports initial and read-pending work before any terminal observation", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false });
await waitForRead(session, 1);
expect(model.getSnapshot()).toEqual({
observation: null,
read: { status: "pending", requested: false },
});
});
it("reports read error as terminal and recovers on refresh", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false });
await waitForRead(session, 1);
session.reads[0].reject(new Error("read failed"));
await vi.waitFor(() =>
expect(model.getSnapshot().read).toEqual({ status: "error", error: "read failed" }),
);
model.refresh();
await waitForRead(session, 2);
session.reads[1].resolve(file("revision-2"));
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
});
it("does not publish a missing candidate when the following read succeeds", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" }));
const publishedStatuses: string[] = [];
model.subscribe(() => {
const observation = model.getObservation();
if (observation) publishedStatuses.push(observation.status);
});
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
await waitForRead(session, 2);
session.reads[1].resolve(file());
await vi.waitFor(() => expect(model.getSnapshot().read).toEqual({ status: "idle" }));
expect(publishedStatuses).not.toContain("missing");
});
it("publishes missing when the following read also fails", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
await waitForRead(session, 2);
session.reads[1].reject(new Error("File not found."));
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing"));
});
it("preserves a missing candidate for its queued confirming read", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
session.reads[0].resolve(file());
await waitForRead(session, 2);
session.reads[1].reject(new Error("File not found."));
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing"));
});
it("recovers only from a read begun after a missing observation", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" }));
model.refresh();
await waitForRead(session, 2);
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
session.reads[1].resolve(file("revision-2", "two"));
await waitForRead(session, 3);
expect(model.getObservation()?.status).toBe("ready");
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
session.reads[2].resolve(file("revision-2", "two"));
await vi.waitFor(() => {
expect(model.getSnapshot()).toMatchObject({
observation: {
status: "ready",
version: { revision: "revision-2" },
file: { revision: "revision-2" },
},
read: { status: "idle" },
});
});
});
it("keeps an actionable error visible while refresh fails", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" }));
session.emit({
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
});
await waitForRead(session, 2);
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false });
session.reads[1].reject(new Error("File unavailable."));
await vi.waitFor(() => {
expect(model.getSnapshot()).toMatchObject({
observation: { status: "error", error: "Requested path is not a file" },
read: { status: "error", error: "File unavailable." },
});
});
model.refresh();
await waitForRead(session, 3);
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
});
it("keeps an update that arrives before the subscription response", async () => {
const session = new DeferredSubscribeSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
session.emit(ready("revision-2"));
session.subscription.resolve({ initial: ready("revision-1"), unsubscribe() {} });
await waitForRead(session, 1);
expect(model.getObservation()).toBeNull();
});
it("retries a failed subscription when the user refreshes", async () => {
const session = new FlakySubscriptionSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
expect(session.subscribeAttempts).toBe(1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
model.refresh();
await waitForRead(session, 2);
expect(session.subscribeAttempts).toBe(2);
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
});
it("reads once without subscribing when live updates are disabled", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: false,
});
await waitForRead(session, 1);
expect(session.subscribeAttempts).toBe(0);
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false });
});
it("ignores a read that completes after the model closes", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
model.close();
session.reads[0].resolve(file());
await Promise.resolve();
expect(model.getSnapshot()).toEqual({
observation: null,
read: { status: "idle" },
});
});
});
@@ -0,0 +1,272 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
export interface LiveFileTarget {
cwd: string;
path: string;
}
export interface LiveFileSubscription {
initial: FileVersion;
unsubscribe(): void;
}
export interface LiveFileSession {
subscribe(
target: LiveFileTarget,
onVersion: (version: FileVersion) => void,
): Promise<LiveFileSubscription>;
read(target: LiveFileTarget): Promise<FileReadResult>;
}
export type LiveFileReadState =
| { status: "idle" }
| { status: "pending"; requested: boolean }
| { status: "error"; error: string };
export type LiveFileObservation =
| {
status: "ready";
version: Extract<FileVersion, { status: "ready" }>;
file: FileReadResult;
}
| Extract<FileVersion, { status: "missing" | "error" }>;
export interface LiveFileSnapshot {
observation: LiveFileObservation | null;
read: LiveFileReadState;
}
const idleSnapshot: LiveFileSnapshot = {
observation: null,
read: { status: "idle" },
};
export class LiveFileModel {
private readonly listeners = new Set<() => void>();
private snapshot: LiveFileSnapshot = idleSnapshot;
private session: LiveFileSession | null = null;
private target: LiveFileTarget | null = null;
private liveUpdates = false;
private unsubscribe: (() => void) | null = null;
private subscriptionPending = false;
private generation = 0;
private issuedReadSequence = 0;
private activeReadSequence: number | null = null;
private activeReadRequested = false;
private observedAfterReadSequence: number | null = null;
private candidateVersion: FileVersion | null = null;
private refreshQueued = false;
private requestedRefreshQueued = false;
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): LiveFileSnapshot => this.snapshot;
getObservation = (): LiveFileObservation | null => this.snapshot.observation;
open(input: { session: LiveFileSession; target: LiveFileTarget; liveUpdates: boolean }): void {
this.reset();
this.session = input.session;
this.target = input.target;
this.liveUpdates = input.liveUpdates;
const generation = this.generation;
if (!input.liveUpdates) {
this.scheduleRead(false);
return;
}
void this.startSubscription(generation);
}
close(): void {
this.reset();
}
refresh = (): void => {
if (!this.session || !this.target) return;
if (this.liveUpdates && !this.unsubscribe && !this.subscriptionPending) {
void this.startSubscription(this.generation);
}
this.scheduleRead(true);
};
private scheduleRead(requested: boolean): void {
if (this.activeReadSequence !== null) {
this.refreshQueued = true;
this.activeReadRequested ||= requested;
this.requestedRefreshQueued ||= requested;
if (requested && this.snapshot.read.status === "pending") {
this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested: true } });
}
return;
}
this.startRead(requested);
}
private reset(): void {
this.generation += 1;
this.unsubscribe?.();
this.unsubscribe = null;
this.session = null;
this.target = null;
this.liveUpdates = false;
this.subscriptionPending = false;
this.issuedReadSequence = 0;
this.activeReadSequence = null;
this.activeReadRequested = false;
this.observedAfterReadSequence = null;
this.candidateVersion = null;
this.refreshQueued = false;
this.requestedRefreshQueued = false;
this.setSnapshot(idleSnapshot);
}
private async startSubscription(generation: number): Promise<void> {
const session = this.session;
const target = this.target;
if (!session || !target) return;
this.subscriptionPending = true;
let receivedUpdate = false;
try {
const subscription = await session.subscribe(target, (version) => {
if (generation !== this.generation) return;
receivedUpdate = true;
this.observeVersion(version);
});
if (generation !== this.generation) {
subscription.unsubscribe();
return;
}
this.subscriptionPending = false;
this.unsubscribe = subscription.unsubscribe;
if (!receivedUpdate) this.observeVersion(subscription.initial);
} catch {
if (generation !== this.generation) return;
this.subscriptionPending = false;
this.scheduleRead(false);
}
}
private observeVersion(version: FileVersion): void {
this.observedAfterReadSequence = this.issuedReadSequence;
this.candidateVersion = version;
this.scheduleRead(false);
}
private startRead(requested: boolean): void {
const session = this.session;
const target = this.target;
if (!session || !target) return;
const generation = this.generation;
const sequence = ++this.issuedReadSequence;
this.activeReadSequence = sequence;
this.activeReadRequested = requested;
this.refreshQueued = false;
this.requestedRefreshQueued = false;
this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested } });
void session.read(target).then(
(file) => this.finishRead({ generation, sequence, target, file }),
(error) => this.failRead({ generation, sequence, target, error }),
);
}
private finishRead(input: {
generation: number;
sequence: number;
target: LiveFileTarget;
file: FileReadResult;
}): void {
if (!this.isActiveRead(input.generation, input.sequence)) return;
const requested = this.activeReadRequested;
this.activeReadSequence = null;
this.activeReadRequested = false;
if (this.readPredatesLatestObservation(input.sequence)) {
this.startRead(requested || this.takeQueuedRequested());
return;
}
this.observedAfterReadSequence = null;
this.candidateVersion = null;
this.setSnapshot({
observation: {
status: "ready",
file: input.file,
version: readyVersion(input.target, input.file),
},
read: { status: "idle" },
});
this.startQueuedRead();
}
private failRead(input: {
generation: number;
sequence: number;
target: LiveFileTarget;
error: unknown;
}): void {
if (!this.isActiveRead(input.generation, input.sequence)) return;
const requested = this.activeReadRequested;
this.activeReadSequence = null;
this.activeReadRequested = false;
if (this.readPredatesLatestObservation(input.sequence)) {
this.startRead(requested || this.takeQueuedRequested());
return;
}
const message = errorMessage(input.error);
const candidate = this.candidateVersion;
this.candidateVersion = null;
const candidateDescribesFailure =
candidate?.status === "missing" || candidate?.status === "error";
const observation: LiveFileObservation = candidateDescribesFailure
? candidate
: { status: "error", cwd: input.target.cwd, path: input.target.path, error: message };
this.setSnapshot({ ...this.snapshot, observation, read: { status: "error", error: message } });
this.startQueuedRead();
}
private isActiveRead(generation: number, sequence: number): boolean {
return generation === this.generation && sequence === this.activeReadSequence;
}
private readPredatesLatestObservation(sequence: number): boolean {
return this.observedAfterReadSequence !== null && sequence <= this.observedAfterReadSequence;
}
private startQueuedRead(): void {
if (!this.refreshQueued) return;
this.startRead(this.takeQueuedRequested());
}
private takeQueuedRequested(): boolean {
const requested = this.requestedRefreshQueued;
this.refreshQueued = false;
this.requestedRefreshQueued = false;
return requested;
}
private setSnapshot(snapshot: LiveFileSnapshot): void {
if (snapshot === this.snapshot) return;
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
}
function readyVersion(
target: LiveFileTarget,
file: FileReadResult,
): Extract<FileVersion, { status: "ready" }> {
return {
status: "ready",
cwd: target.cwd,
path: target.path,
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+590 -116
View File
@@ -1,27 +1,194 @@
import { useEffect, useMemo, useState } from "react";
import type { PluginFile, PluginFileTab } from "@getpaseo/plugin/client";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useFileHandle, useFileHandleSnapshot } from "@/file-panels/file";
import { FilePanelLoading } from "@/file-panels/loading";
import { FilePanelPicker, FilePanelView, useFilePanelChoice, type FilePanel } from "@/file-panels";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { FilePanelPreview, resolveFilePreviewPanel, type FilePanel } from "@/file-panels";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { getFileNameFromPath } from "@/attachments/utils";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import { useSessionStore } from "@/stores/session-store";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { formatFileSize } from "@/utils/format-file-size";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
import { isWeb } from "@/constants/platform";
import { useAppSettings } from "@/hooks/use-settings";
import { useLiveFile } from "./live-file/hook";
import { useFilePreview } from "./preview-lifecycle/hook";
import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model";
import { FilePanelBar } from "./bar";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
import { FileSourceView } from "./source/view";
import type { FileConflictAlertState } from "./conflict-alert";
import type { LiveFileModel } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import type { Theme } from "@/styles/theme";
// CodeMirror and native source rendering consume appearance as props. Subscribe at the
// rendering leaf so theme updates reconfigure it without replacing the editor/model.
const sourceAppearance = (theme: Theme) => ({
theme: {
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,
},
});
const ThemedFileEditorView = withUnistyles(FileEditorView, sourceAppearance);
const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
interface FilePreviewBodyProps {
panel: FilePanel | null;
preview: ExplorerFile | null;
mode?: "preview" | "source";
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
}
type TextExplorerFile = ExplorerFile & { kind: "text" };
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function ReadonlySource({
preview,
filename,
location,
navigationRevision,
}: {
preview: ExplorerFile;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const { t } = useTranslation();
return (
<ThemedFileSourceView
content={preview.content ?? ""}
filename={filename}
location={location}
navigationRevision={navigationRevision}
size={preview.size}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
function TooLargeSource({ size }: { size?: number }) {
const { t } = useTranslation();
return (
<View style={styles.centerState} testID="file-source-too-large">
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
</View>
);
}
function FilePreviewBody({
panel,
preview,
mode,
isLoading,
isMobile: _isMobile,
location,
navigationRevision,
imagePreviewUri,
workspaceRoot,
client,
serverId,
}: FilePreviewBodyProps) {
const { t } = useTranslation();
const filePath = location.path;
if (isLoading && !preview) {
return (
<View style={styles.centerState} testID="file-preview-loading">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
if (!preview) {
return (
<View style={styles.centerState} testID="file-preview-unsupported">
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
</View>
);
}
const showSource = preview.kind === "text" && (Boolean(location.lineStart) || mode === "source");
if (panel && !showSource) {
return (
<FilePanelPreview
panel={panel}
content={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
imagePreviewUri={imagePreviewUri}
/>
);
}
if (preview.kind === "text") {
return (
<ReadonlySource
preview={preview}
filename={filePath}
location={location}
navigationRevision={navigationRevision}
/>
);
}
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
}
/**
* The file tab: stats the file, picks its panel and mounts it. The panel reads, saves and shows
* its own loading; the tab only knows the file exists, its kind, and whether the panel has
* unsaved changes.
*/
export function FilePane({
serverId,
workspaceRoot,
@@ -34,120 +201,417 @@ export function FilePane({
navigationRevision: number;
}) {
const { t } = useTranslation();
const connected = useSessionStore((state) => Boolean(state.sessions[serverId]?.client));
const root = workspaceRoot.trim();
const path = location.path.trim();
const target = useMemo(
() => (path ? resolveFilePreviewReadTarget({ path, workspaceRoot: root }) : null),
[path, root],
const { settings } = useAppSettings();
const isMobile = useIsCompactFormFactor();
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
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 absolutePath = useMemo(
() => buildAbsoluteExplorerPath({ workspaceRoot: root, entryPath: path }),
[path, root],
);
// A hidden tab or a backgrounded app stops observing; observing again reports what changed.
const isActive = useRetainedPanelActive();
const isAppVisible = useAppActivelyVisible();
const handle = useFileHandle({
serverId,
target,
absolutePath,
observing: isActive && isAppVisible,
});
const snapshot = useFileHandleSnapshot(handle);
const targetKey = target ? `${target.cwd}:${target.path}` : null;
const { panel, choice } = useFilePanelChoice({
serverId,
targetKey,
path,
fileKind: snapshot.status === "ready" ? snapshot.kind : null,
presentable: isActive,
lineNavigation: location.lineStart ? navigationRevision : null,
});
const [modified, setModified] = useState(false);
usePublishPanelInstanceAttributes({ modified, suspendPendingSave: handle?.holdWrites });
const panelLocation = useMemo(
() => ({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
revision: navigationRevision,
}),
[location.lineEnd, location.lineStart, navigationRevision],
);
const panelTarget = useMemo(
() => ({ serverId, workspaceRoot: root, path }),
[path, root, serverId],
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const readTarget = useMemo(
() =>
normalizedFilePath
? resolveFilePreviewReadTarget({
path: normalizedFilePath,
workspaceRoot: normalizedWorkspaceRoot,
})
: null,
[normalizedFilePath, normalizedWorkspaceRoot],
);
let body;
if (!connected && target) {
body = <Text style={styles.errorText}>{t("workspace.terminal.hostDisconnected")}</Text>;
} else if (!handle || snapshot.status === "loading") {
// Re-read the file when this pane becomes visible again (#445). `isActive`
// covers tab switches; active app visibility covers backgrounding and returning
// from another window after an external edit. The gate lives in isFileQueryEnabled.
const isActive = useRetainedPanelActive();
const isAppVisible = useAppActivelyVisible();
const enabled = isFileQueryEnabled({
hasReadTarget: Boolean(client && readTarget),
isTabActive: isActive,
isAppVisible,
});
const liveFile = useLiveFile({
client,
cwd: readTarget?.cwd ?? null,
path: readTarget?.path ?? null,
enabled,
liveUpdates: supportsEditing,
});
const targetKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
const previewLifecycle = useFilePreview({
targetKey,
liveFileSnapshot: liveFile.snapshot,
});
useEffect(() => setPreviewMode("preview"), [targetKey]);
const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle);
const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment);
const panel = resolveFilePreviewPanel({
preview,
path: location.path,
associations: settings.filePanelAssociations,
});
const isRenderable = preview?.kind === "text" && panel !== null;
const editable = isEditableTextFile({
preview,
supportsEditing,
});
const canTogglePreviewMode = isRenderable && !location.lineStart;
const lineCount =
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
const errorMessage = previewLifecycle.status === "error" ? previewLifecycle.message : null;
const isLoading =
previewLifecycle.status === "initial" ||
previewLifecycle.status === "read_pending" ||
previewLifecycle.status === "preparing";
return (
<FilePanePresentation
panel={panel}
serverId={serverId}
workspaceRoot={normalizedWorkspaceRoot}
client={client}
readTarget={readTarget}
preview={preview}
liveFile={liveFile.model}
onRetryRead={liveFile.refresh}
retryingRead={liveFile.isRetrying}
retryLabel={t("common.actions.retry")}
filename={getFileNameFromPath(location.path) ?? location.path}
previewMode={canTogglePreviewMode ? previewMode : undefined}
onPreviewModeChange={canTogglePreviewMode ? setPreviewMode : undefined}
lineCount={lineCount}
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
errorMessage={errorMessage}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
/>
);
}
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({
panel,
serverId,
workspaceRoot,
client,
readTarget,
preview,
liveFile,
onRetryRead,
retryingRead,
retryLabel,
filename,
previewMode,
onPreviewModeChange,
lineCount,
editable,
disconnectedMessage,
errorMessage,
isLoading,
isMobile,
location,
navigationRevision,
imagePreviewUri,
}: {
panel: FilePanel | null;
serverId: string;
workspaceRoot: string;
client: DaemonClient | null;
readTarget: { cwd: string; path: string } | null;
preview: ExplorerFile | null;
liveFile: LiveFileModel;
onRetryRead: () => void;
retryingRead: boolean;
retryLabel: string;
filename: string;
previewMode?: "preview" | "source";
onPreviewModeChange?: (mode: "preview" | "source") => void;
lineCount?: number;
editable: boolean;
disconnectedMessage: string;
errorMessage: string | null;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
}) {
if (!client && readTarget) {
return (
<View style={styles.container} testID="workspace-file-pane">
<FilePanelLoading />
</View>
);
} else if (snapshot.status === "missing" || snapshot.status === "error") {
body = (
<>
<Text style={styles.errorText}>
{snapshot.status === "error" ? snapshot.error : t("panels.file.notFound")}
</Text>
<Button variant="outline" size="sm" onPress={handle.retry}>
{t("common.actions.retry")}
</Button>
</>
);
} else if (!panel) {
body = (
<>
<Text style={styles.mutedText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.mutedText}>{formatFileSize(snapshot.size)}</Text>
</>
);
} else {
return (
<View style={styles.container} testID="workspace-file-pane">
<FilePanelPicker choice={choice} />
<FilePanelBar choice={choice} modified={modified} />
<MountedFilePanel
key={`${panel.id}:${targetKey}`}
panel={panel}
file={handle.file}
location={panelLocation}
target={panelTarget}
onModifiedChange={setModified}
/>
<View style={styles.centerState}>
<Text style={styles.errorText}>{disconnectedMessage}</Text>
</View>
</View>
);
}
if (editable && client && readTarget && preview?.kind === "text") {
return (
<EditableFilePane
panel={panel}
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
serverId={serverId}
workspaceRoot={workspaceRoot}
client={client}
cwd={readTarget.cwd}
path={readTarget.path}
preview={preview as TextExplorerFile}
liveFile={liveFile}
onRetryRead={onRetryRead}
retryingRead={retryingRead}
filename={filename}
mode={previewMode}
onModeChange={onPreviewModeChange}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
/>
);
}
if (errorMessage) {
if (errorMessage === "File is too large to display") {
return (
<View style={styles.container} testID="workspace-file-pane">
<TooLargeSource />
</View>
);
}
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>
<Text style={styles.errorText}>{errorMessage}</Text>
<Button variant="outline" size="sm" onPress={onRetryRead} loading={retryingRead}>
{retryLabel}
</Button>
</View>
</View>
);
}
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>{body}</View>
{preview ? (
<FilePanelBar
size={preview.size}
lineCount={lineCount}
mode={previewMode}
onModeChange={onPreviewModeChange}
/>
) : null}
<FilePreviewBody
panel={panel}
preview={preview}
mode={previewMode}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
</View>
);
}
/** One panel mount. Its tab reports unsaved changes until the panel unmounts. */
function MountedFilePanel({
function EditableFilePane({
panel,
file,
serverId,
workspaceRoot,
client,
cwd,
path,
preview,
liveFile,
onRetryRead,
retryingRead,
filename,
mode,
onModeChange,
isLoading,
isMobile,
location,
target,
onModifiedChange,
navigationRevision,
}: {
panel: FilePanel;
file: PluginFile;
location: { lineStart?: number; lineEnd?: number; revision: number };
target: { serverId: string; workspaceRoot: string; path: string };
onModifiedChange: (modified: boolean) => void;
panel: FilePanel | null;
serverId: string;
workspaceRoot: string;
client: DaemonClient;
cwd: string;
path: string;
preview: TextExplorerFile;
liveFile: LiveFileModel;
onRetryRead: () => void;
retryingRead: boolean;
filename: string;
mode?: "preview" | "source";
onModeChange?: (mode: "preview" | "source") => void;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const [tab] = useState<PluginFileTab>(() => ({
setModified: (modified) => onModifiedChange(modified),
}));
useEffect(() => () => onModifiedChange(false), [onModifiedChange]);
return <FilePanelView panel={panel} file={file} tab={tab} location={location} target={target} />;
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(
() => ({
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
return client.writeFile({ cwd, path, ...input });
},
}),
[client, cwd, path],
);
const [model] = useState(() => {
return new FileEditorModel({
file: {
content: preview.content ?? "",
hasBom: preview.hasBom,
version: {
status: "ready",
cwd,
path,
size: preview.size,
modifiedAt: preview.modifiedAt,
revision: preview.revision,
},
},
session,
});
});
useEffect(() => {
const source = createFileObservationSource(liveFile);
model.connectFileObservations(source);
return () => model.disconnectFileObservations();
}, [liveFile, model]);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
useEffect(() => () => model.dispose(), [model]);
const handleReload = useCallback(() => {
if (!snapshot.modified) {
void model.reload();
return;
}
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, snapshot.modified, t]);
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
const conflict = fileConflictAlertState({
callout: getFileConflictCallout(snapshot),
onOverwrite: handleOverwrite,
onReload: handleReload,
onRetry: onRetryRead,
retrying: retryingRead,
});
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}
conflict={conflict}
mode={mode}
onModeChange={onModeChange}
/>
{showSource ? (
<ThemedFileEditorView
model={model}
filename={filename}
location={location}
navigationRevision={navigationRevision}
vimEnabled={settings.vimKeybindings}
onCursorChange={setCursor}
onVimModeChange={handleVimModeChange}
/>
) : (
<FilePreviewBody
panel={panel}
preview={renderedPreview}
mode={mode}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={null}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
)}
</View>
);
}
function fileConflictAlertState(input: {
callout: FileConflictCallout | null;
onOverwrite(): void;
onReload(): void;
onRetry(): void;
retrying: boolean;
}): FileConflictAlertState | undefined {
if (!input.callout) return undefined;
if (input.callout.kind === "deleted") return { kind: "deleted" };
if (input.callout.kind === "checkFailed") {
return { kind: "checkFailed", retrying: input.retrying, onRetry: input.onRetry };
}
return {
kind: "changed",
canOverwrite: input.callout.canOverwrite,
onReload: input.onReload,
onOverwrite: input.onOverwrite,
};
}
const styles = StyleSheet.create((theme) => ({
@@ -163,14 +627,24 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
padding: theme.spacing[4],
},
loadingText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.base,
textAlign: "center",
},
mutedText: {
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
},
binaryMetaText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
@@ -0,0 +1,23 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import type { LiveFileSnapshot } from "../live-file/model";
import {
createFilePanePreview,
FilePreviewLifecycleModel,
type FilePreviewLifecycleSnapshot,
} from "./model";
/** Bridges the raw-file owner to the preview preparation owner. */
export function useFilePreview(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
}): FilePreviewLifecycleSnapshot {
const [model] = useState(() => new FilePreviewLifecycleModel(createFilePanePreview));
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const { targetKey, liveFileSnapshot } = input;
useEffect(() => {
model.setSource({ targetKey, liveFileSnapshot });
}, [liveFileSnapshot, model, targetKey]);
return snapshot;
}
@@ -0,0 +1,157 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { LiveFileSnapshot } from "../live-file/model";
import { describe, expect, it } from "vitest";
import {
FilePreviewLifecycleModel,
type FilePanePreview,
type FilePreviewLifecycleSnapshot,
} from "./model";
function previewFile(content: string) {
return {
kind: "text" as const,
path: "file.ts",
size: 7,
encoding: "utf-8" as const,
hasBom: false,
modifiedAt: "2026-08-20T00:00:00.000Z",
content,
};
}
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: Error): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
function file(revision = "revision-1", path = "file.ts"): FileReadResult {
return {
bytes: new TextEncoder().encode("preview"),
mime: "text/plain",
size: 7,
path,
kind: "text",
modifiedAt: "2026-08-20T00:00:00.000Z",
revision,
};
}
function pending(): LiveFileSnapshot {
return { observation: null, read: { status: "pending", requested: false } };
}
function completed(rawFile: FileReadResult): LiveFileSnapshot {
return {
observation: {
status: "ready",
file: rawFile,
version: {
status: "ready",
cwd: "/workspace",
path: rawFile.path,
size: rawFile.size,
modifiedAt: rawFile.modifiedAt,
revision: rawFile.revision,
},
},
read: { status: "idle" },
};
}
function source(
targetKey: string,
snapshot: LiveFileSnapshot,
): {
targetKey: string;
liveFileSnapshot: LiveFileSnapshot;
} {
return { targetKey, liveFileSnapshot: snapshot };
}
describe("FilePreviewLifecycleModel", () => {
it("represents pending raw reads, preparation, ready previews, unsupported files, and conversion failures", async () => {
const first = deferred<FilePanePreview | null>();
const second = deferred<FilePanePreview | null>();
const third = deferred<FilePanePreview | null>();
const preparations = [first, second, third];
const model = new FilePreviewLifecycleModel(() => preparations.shift()!.promise);
const preview: FilePanePreview = {
file: previewFile("preview"),
imageAttachment: null,
};
model.setSource(source("/workspace:file.ts", pending()));
expect(model.getSnapshot()).toEqual({ status: "read_pending" });
model.setSource(source("/workspace:file.ts", completed(file("one"))));
expect(model.getSnapshot()).toEqual({ status: "preparing" });
first.resolve(preview);
await expectSnapshot(model, { status: "ready", preview });
model.setSource(source("/workspace:file.ts", pending()));
expect(model.getSnapshot()).toEqual({ status: "read_pending", preview });
model.setSource(
source("/workspace:file.ts", {
observation: {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
},
read: { status: "pending", requested: true },
}),
);
expect(model.getSnapshot()).toEqual({ status: "read_pending", preview });
model.setSource(source("/workspace:file.ts", completed(file("two"))));
expect(model.getSnapshot()).toEqual({ status: "preparing", preview });
second.resolve(null);
await expectSnapshot(model, { status: "unsupported" });
model.setSource(source("/workspace:file.ts", completed(file("three"))));
expect(model.getSnapshot()).toEqual({ status: "preparing" });
third.reject(new Error("attachment failed"));
await expectSnapshot(model, { status: "error", message: "attachment failed" });
});
it("does not publish a stale conversion after retargeting", async () => {
const first = deferred<FilePanePreview | null>();
const second = deferred<FilePanePreview | null>();
const model = new FilePreviewLifecycleModel((rawFile) =>
rawFile.revision === "one" ? first.promise : second.promise,
);
const nextPreview: FilePanePreview = {
file: previewFile("two"),
imageAttachment: null,
};
model.setSource(source("/workspace:file.ts", completed(file("one"))));
model.setSource(source("/workspace:second.ts", completed(file("two", "second.ts"))));
first.resolve({ file: previewFile("one"), imageAttachment: null });
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "preparing" });
second.resolve(nextPreview);
await expectSnapshot(model, { status: "ready", preview: nextPreview });
});
});
async function expectSnapshot(
model: FilePreviewLifecycleModel,
expected: FilePreviewLifecycleSnapshot,
): Promise<void> {
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(model.getSnapshot()).toEqual(expected);
}
@@ -0,0 +1,190 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { ExplorerFile } from "@/stores/session-store";
import type { LiveFileSnapshot } from "../live-file/model";
export interface FilePanePreview {
file: ExplorerFile;
imageAttachment: AttachmentMetadata | null;
}
export type FilePreviewLifecycleSnapshot =
| { status: "initial" }
| { status: "read_pending"; preview?: FilePanePreview }
| { status: "preparing"; preview?: FilePanePreview }
| { status: "ready"; preview: FilePanePreview }
| { status: "error"; message: string; preview?: FilePanePreview }
| { status: "unsupported" };
const initialSnapshot: FilePreviewLifecycleSnapshot = { status: "initial" };
/** Converts a completed raw read into the preview resources consumed by FilePane. */
export async function createFilePanePreview(file: FileReadResult): Promise<FilePanePreview | null> {
const explorerFile = explorerFileFromReadResult(file);
if (file.kind !== "image") {
return { file: explorerFile, imageAttachment: null };
}
const imageAttachment = await persistAttachmentFromBytes({
id: createPreviewAttachmentId({
mimeType: file.mime,
path: file.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
}),
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path),
});
return { file: explorerFile, imageAttachment };
}
/** Owns conversion after LiveFileModel has produced a raw file snapshot. */
export class FilePreviewLifecycleModel {
private readonly listeners = new Set<() => void>();
private snapshot: FilePreviewLifecycleSnapshot = initialSnapshot;
private generation = 0;
private preparedSourceKey: string | null = null;
private targetKey: string | null = null;
constructor(
private readonly prepare: (file: FileReadResult) => Promise<FilePanePreview | null>,
) {}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FilePreviewLifecycleSnapshot => this.snapshot;
setSource(input: { targetKey: string | null; liveFileSnapshot: LiveFileSnapshot }): void {
const targetChanged = input.targetKey !== this.targetKey;
const retainedPreview = targetChanged ? undefined : filePreviewFromLifecycle(this.snapshot);
if (targetChanged) {
this.targetKey = input.targetKey;
this.preparedSourceKey = null;
this.generation += 1;
}
const source = getReadySource(input);
if (source) {
if (source.key === this.preparedSourceKey) {
return;
}
this.preparedSourceKey = source.key;
const generation = ++this.generation;
this.setSnapshot({
status: "preparing",
...(retainedPreview ? { preview: retainedPreview } : {}),
});
void this.prepareSource({ file: source.file, generation, retainedPreview });
return;
}
const nextSnapshot = getNonReadySnapshot(input.liveFileSnapshot);
if (nextSnapshot.status === "initial" && retainedPreview) {
return;
}
const retainedSnapshot = retainedPreview
? { ...nextSnapshot, preview: retainedPreview }
: nextSnapshot;
if (sameNonReadyState(this.snapshot, retainedSnapshot)) {
return;
}
this.preparedSourceKey = null;
this.generation += 1;
this.setSnapshot(retainedSnapshot);
}
private setSnapshot(snapshot: FilePreviewLifecycleSnapshot): void {
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
private async prepareSource(input: {
file: FileReadResult;
generation: number;
retainedPreview?: FilePanePreview;
}): Promise<void> {
try {
const preview = await this.prepare(input.file);
if (input.generation !== this.generation) return;
this.setSnapshot(preview ? { status: "ready", preview } : { status: "unsupported" });
} catch (error) {
if (input.generation !== this.generation) return;
this.setSnapshot({
status: "error",
message: errorMessage(error),
...(input.retainedPreview ? { preview: input.retainedPreview } : {}),
});
}
}
}
export function filePreviewFromLifecycle(
snapshot: FilePreviewLifecycleSnapshot,
): FilePanePreview | undefined {
return "preview" in snapshot ? snapshot.preview : undefined;
}
export function resolveFilePreviewLifecycle(snapshot: FilePreviewLifecycleSnapshot): {
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
} {
const preview = filePreviewFromLifecycle(snapshot);
return preview ?? { file: null, imageAttachment: null };
}
function getReadySource(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
}): { key: string; file: FileReadResult } | null {
const observation = input.liveFileSnapshot.observation;
if (!input.targetKey || observation?.status !== "ready") {
return null;
}
const { version } = observation;
if (input.targetKey !== `${version.cwd}:${version.path}`) {
return null;
}
return {
key: `${input.targetKey}:${version.revision ?? version.modifiedAt}:${version.size}`,
file: observation.file,
};
}
function getNonReadySnapshot(snapshot: LiveFileSnapshot): FilePreviewLifecycleSnapshot {
if (snapshot.read.status === "error") {
return { status: "error", message: snapshot.read.error };
}
if (snapshot.read.status === "pending") {
return { status: "read_pending" };
}
if (snapshot.observation?.status === "error") {
return { status: "error", message: snapshot.observation.error };
}
if (snapshot.observation?.status === "missing") {
return { status: "error", message: "File not found" };
}
return initialSnapshot;
}
function sameNonReadyState(
current: FilePreviewLifecycleSnapshot,
next: FilePreviewLifecycleSnapshot,
): boolean {
if (current.status !== next.status) {
return false;
}
return current.status !== "error" || next.status !== "error" || current.message === next.message;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Failed to prepare preview";
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View, type ListRenderItem } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { selectSourcePresentation } from "./presentation";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
interface SourceLine {
number: number;
tokens: HighlightToken[];
}
export function FileSourceView({
content,
filename,
location,
navigationRevision,
size,
tooLargeMessage,
}: FileSourceViewProps) {
const presentation = selectSourcePresentation({ size, platform: "native" });
if (presentation === "unsupported") {
return (
<View style={styles.unsupported} testID="file-source-too-large">
<Text style={styles.unsupportedText}>{tooLargeMessage}</Text>
</View>
);
}
return (
<VirtualizedSource
content={content}
filename={filename}
location={location}
navigationRevision={navigationRevision}
presentation={presentation}
/>
);
}
function VirtualizedSource({
content,
filename,
location,
navigationRevision,
presentation,
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
presentation: "highlighted" | "plain";
}) {
const listRef = useRef<FlatList<SourceLine>>(null);
const lines = useMemo(() => {
if (presentation === "highlighted")
return highlightCode(content, filename).map((tokens, index) => ({
number: index + 1,
tokens,
}));
return content
.split("\n")
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
}, [content, filename, presentation]);
useEffect(() => {
if (!location.lineStart) return;
listRef.current?.scrollToIndex({
index: Math.min(location.lineStart - 1, lines.length - 1),
animated: false,
viewPosition: 0.5,
});
}, [lines.length, location.lineStart, navigationRevision]);
return (
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
renderItem={renderSourceLine}
/>
);
}
function SourceLineView({ line }: { line: SourceLine }) {
return (
<View style={styles.line}>
<Text style={styles.gutter}>{line.number}</Text>
<Text selectable style={styles.text}>
{line.tokens.map((token) => (
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
{token.text}
</Text>
))}
</Text>
</View>
);
}
function sourceLineKey(line: SourceLine): string {
return String(line.number);
}
function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index: number) {
return { length: 20, offset: index * 20, index };
}
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
const styles = StyleSheet.create((theme) => ({
unsupported: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
gutter: {
width: 56,
paddingRight: theme.spacing[3],
textAlign: "right",
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
text: {
flex: 1,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
},
}));
@@ -6,34 +6,63 @@ import { getLanguageForFile } from "@getpaseo/highlight";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { editorTheme } from "../editor/extensions.web";
import { FilePanelLoading } from "../../loading";
import type { SourcePresentation } from "./presentation";
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
presentation: Exclude<SourcePresentation, "unsupported">;
size: number;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
const languageCompartment = new Compartment();
const themeCompartment = new Compartment();
/** A read-only source view for files Source does not edit. Shows Loading until the editor exists. */
export function FileSourceView({
content,
filename,
location,
navigationRevision,
size,
theme,
tooLargeMessage,
}: FileSourceViewProps) {
const presentation = selectSourcePresentation({ size, platform: "web" });
if (presentation === "unsupported") {
return (
<div data-testid="file-source-too-large" style={UNSUPPORTED_STYLE}>
{tooLargeMessage}
</div>
);
}
return (
<ReadonlyCodeMirror
content={content}
filename={filename}
location={location}
navigationRevision={navigationRevision}
presentation={presentation}
theme={theme}
/>
);
}
function ReadonlyCodeMirror({
content,
filename,
location,
navigationRevision,
presentation,
theme,
}: FileSourceViewProps) {
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
presentation: Exclude<SourcePresentation, "unsupported">;
}) {
const [find] = useState(() => new FileFindModel());
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const [ready, setReady] = useState(false);
const initial = useRef({ content, filename, presentation, theme });
useEffect(() => {
@@ -59,7 +88,6 @@ export function FileSourceView({
}),
});
viewRef.current = view;
setReady(true);
return () => {
view.destroy();
viewRef.current = null;
@@ -93,7 +121,6 @@ export function FileSourceView({
<div style={FRAME_STYLE}>
<div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />
<FileFind model={find} editor={viewRef} />
{ready ? null : <FilePanelLoading />}
</div>
);
}
@@ -115,3 +142,9 @@ const FRAME_STYLE = {
minWidth: 0,
} as const;
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
const UNSUPPORTED_STYLE = {
alignItems: "center",
display: "flex",
flex: 1,
justifyContent: "center",
} as const;
@@ -1,34 +0,0 @@
import { useCallback } from "react";
import { useAppSettings } from "@/hooks/use-settings";
export interface FilePanelAssociations {
/** Selector key (`ext:md`, `name:Dockerfile`) → panel id, device-wide. */
associations: Readonly<Record<string, string>>;
/** Writes the association for a selector key, or forgets it with null. */
remember(selectorKey: string, panelId: string | null): void;
}
/** The one reader and writer of the device's file panel associations. */
export function useFilePanelAssociations(): FilePanelAssociations {
const { settings, updateSettings } = useAppSettings();
const remember = useCallback(
(selectorKey: string, panelId: string | null) => {
void updateSettings((current) => ({
filePanelAssociations: withAssociation(current.filePanelAssociations, selectorKey, panelId),
}));
},
[updateSettings],
);
return { associations: settings.filePanelAssociations, remember };
}
function withAssociation(
associations: Readonly<Record<string, string>>,
selectorKey: string,
panelId: string | null,
): Record<string, string> {
const next = { ...associations };
if (panelId === null) delete next[selectorKey];
else next[selectorKey] = panelId;
return next;
}
+51 -53
View File
@@ -1,94 +1,92 @@
import { useFileText, useFileUrl } from "@getpaseo/plugin/client";
import { useCallback, useState } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import { useSessionStore } from "@/stores/session-store";
import type { Theme } from "@/styles/theme";
import { FileHtmlPreview } from "./html-preview";
import { FilePanelLoading } from "./loading";
import { FileMarkdownPreview } from "./markdown-preview";
import type { BuiltinFilePanelProps } from "./panels";
import { SourcePanel } from "./source/panel";
import type { RenderingFilePanel, SourceFilePanel } from "./catalog";
import type { builtinFilePanels } from "./catalog";
/** Shows Loading until the rendered Markdown has laid out once; later re-reads keep the content. */
function MarkdownPanel({ file, target }: BuiltinFilePanelProps) {
const text = useFileText(file);
const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null);
const [rendered, setRendered] = useState(false);
const handleLayout = useCallback(() => setRendered(true), []);
if (text.status === "loading") return <FilePanelLoading />;
if (text.status === "error") return <FilePanelError message={text.error.message} />;
interface FilePanelPreviewProps {
content: string;
filePath: string;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
imagePreviewUri: string | null;
}
function MarkdownPanel(props: FilePanelPreviewProps) {
return (
<View style={styles.container}>
<ScrollView style={styles.container} showsVerticalScrollIndicator>
<View onLayout={handleLayout}>
<FileMarkdownPreview
source={text.text}
filePath={target.path}
workspaceRoot={target.workspaceRoot}
client={client}
serverId={target.serverId}
/>
</View>
<View style={styles.previewScrollContainer}>
<ScrollView style={styles.previewContent} showsVerticalScrollIndicator>
<FileMarkdownPreview
source={props.content}
filePath={props.filePath}
workspaceRoot={props.workspaceRoot}
client={props.client}
serverId={props.serverId}
/>
</ScrollView>
{rendered ? null : <FilePanelLoading />}
</View>
);
}
function HtmlPanel({ file }: BuiltinFilePanelProps) {
const text = useFileText(file);
if (text.status === "loading") return <FilePanelLoading />;
if (text.status === "error") return <FilePanelError message={text.error.message} />;
function HtmlPanel({ content }: FilePanelPreviewProps) {
return (
<View style={styles.container}>
<FileHtmlPreview html={text.text} testID="file-html-preview" />
<View style={styles.previewScrollContainer}>
<FileHtmlPreview html={content} testID="file-html-preview" />
</View>
);
}
function ImagePanel({ file }: BuiltinFilePanelProps) {
const url = useFileUrl(file);
if (url.status === "loading") return <FilePanelLoading />;
if (url.status === "error") return <FilePanelError message={url.error.message} />;
return <ZoomableImage uri={url.url} testID="image-file-preview" />;
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function FilePanelError({ message }: { message: string }) {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>{message}</Text>
</View>
);
function ImagePanel({ imagePreviewUri }: FilePanelPreviewProps) {
const { t } = useTranslation();
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
return <ZoomableImage uri={imagePreviewUri} testID="image-file-preview" />;
}
const components = {
"builtin:markdown": MarkdownPanel,
"builtin:html": HtmlPanel,
"builtin:image": ImagePanel,
"builtin:source": SourcePanel,
};
export function BuiltinFilePanelView({
export function FilePanelPreview({
panel,
...props
}: BuiltinFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) {
}: FilePanelPreviewProps & {
panel: (typeof builtinFilePanels)[number];
}) {
const Component = components[panel.id];
return <Component {...props} />;
}
const styles = StyleSheet.create((theme) => ({
container: { flex: 1, minHeight: 0 },
previewScrollContainer: { flex: 1, minHeight: 0 },
previewContent: { flex: 1, minHeight: 0 },
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
padding: theme.spacing[4],
},
errorText: {
color: theme.colors.destructive,
loadingText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
},
}));
+64 -52
View File
@@ -1,74 +1,86 @@
import { describe, expect, it } from "vitest";
import { filePanelCatalog } from "./catalog";
import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
import { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
import { resolveFilePanel } from "./resolve";
const builtins = filePanelCatalog([]);
const richMarkdown: FilePanelCandidate = {
id: "plugin:notes/rich",
builtin: false,
files: { extensions: ["md"] },
};
function textFile(path: string): FilePanelFile {
return { path, kind: "text" };
function filePreviewRenderKind(path: string) {
const { panel } = resolveFilePanel({
file: { path, kind: "text", size: 100, hasBytes: false },
candidates: builtinFilePanels,
associations: {},
});
return panel ? panel.id.slice("builtin:".length) : null;
}
function candidateIds(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) {
return resolveFilePanel({ file, candidates, associations: {} }).candidates.map(
(candidate) => candidate.id,
);
}
describe("isRenderedMarkdownFile", () => {
it("detects .md files", () => {
expect(isRenderedMarkdownFile("README.md")).toBe(true);
expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true);
});
function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) {
return resolveFilePanel({ file, candidates, associations: {} }).panel?.id ?? null;
}
it("detects .markdown files", () => {
expect(isRenderedMarkdownFile("notes.markdown")).toBe(true);
expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true);
});
it("does not treat .mdx files as rendered markdown", () => {
expect(isRenderedMarkdownFile("page.mdx")).toBe(false);
});
it("does not treat other text files as rendered markdown", () => {
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
expect(isRenderedMarkdownFile("plan.html")).toBe(false);
});
});
describe("filePreviewRenderKind", () => {
it("maps each renderable extension to its kind", () => {
expect(filePreviewRenderKind("README.md")).toBe("markdown");
expect(filePreviewRenderKind("notes.markdown")).toBe("markdown");
expect(filePreviewRenderKind("plan.html")).toBe("html");
expect(filePreviewRenderKind("docs/PLAN.HTML")).toBe("html");
expect(filePreviewRenderKind("plan.htm")).toBe("html");
});
it("returns null for files without a rendered preview", () => {
expect(filePreviewRenderKind("src/index.ts")).toBe(null);
expect(filePreviewRenderKind("page.mdx")).toBe(null);
expect(filePreviewRenderKind("index.html.erb")).toBe(null);
});
});
describe("built-in file panels", () => {
it("opens each rendered extension with its panel and Source after it", () => {
expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown", "builtin:source"]);
expect(panelId(textFile("notes.markdown"))).toBe("builtin:markdown");
expect(panelId(textFile("plan.html"))).toBe("builtin:html");
expect(panelId(textFile("docs/PLAN.HTML"))).toBe("builtin:html");
expect(panelId(textFile("plan.htm"))).toBe("builtin:html");
});
it("opens every other text file with Source, whatever its name", () => {
for (const path of ["src/index.ts", "page.mdx", "index.html.erb", "Dockerfile", "LICENSE"]) {
expect(candidateIds(textFile(path))).toEqual(["builtin:source"]);
}
});
it("puts plugin panels after the rendering built-ins and before Source", () => {
expect(candidateIds(textFile("README.md"), filePanelCatalog([richMarkdown]))).toEqual([
"builtin:markdown",
richMarkdown.id,
"builtin:source",
]);
const onlyPlugin = filePanelCatalog([{ ...richMarkdown, files: { extensions: ["txt"] } }]);
expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id);
});
it("has no Source for files that are not text", () => {
expect(candidateIds({ path: "archive.zip", kind: "binary" })).toEqual([]);
});
it.each(["png", "jpg", "jpeg", "gif", "webp", "svg"])(
"registers the image extension %s",
(extension) => {
const resolution = resolveFilePanel({
file: { path: `/capture.${extension.toUpperCase()}`, kind: "image" },
candidates: builtins,
file: {
path: `/capture.${extension.toUpperCase()}`,
kind: "image",
size: 512,
hasBytes: true,
},
candidates: builtinFilePanels,
associations: { [`ext:${extension}`]: "builtin:markdown" },
});
expect(resolution.panel?.id).toBe("builtin:image");
expect(
resolveFilePanel({
file: { path: "capture.png", kind: "binary" },
candidates: builtins,
file: { path: "capture.png", kind: "binary", size: 512, hasBytes: false },
candidates: builtinFilePanels,
associations: {},
}).panel,
).toBeNull();
},
);
it("does not impose a new preview size limit on built-in text panels", () => {
expect(
resolveFilePanel({
file: { path: "notes.md", kind: "text", size: 11 * 1024 * 1024, hasBytes: false },
candidates: builtinFilePanels,
associations: {},
}).panel?.id,
).toBe("builtin:markdown");
});
});
+16 -31
View File
@@ -1,43 +1,28 @@
import type { FilePanelCandidate } from "./resolve";
import { filePanelExtension, type FilePanelCandidate } from "./resolve";
const renderingPanels = [
{
id: "builtin:markdown",
builtin: true,
files: { extensions: ["md", "markdown"] },
fileKind: "text",
},
const markdownPanel = {
id: "builtin:markdown",
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
} as const satisfies FilePanelCandidate;
export const builtinFilePanels = [
markdownPanel,
{
id: "builtin:html",
builtin: true,
files: { extensions: ["html", "htm"] },
fileKind: "text",
document: "text",
maxBytes: Infinity,
},
{
id: "builtin:image",
builtin: true,
files: { extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] },
fileKind: "image",
document: "bytes",
maxBytes: Infinity,
},
] as const satisfies readonly FilePanelCandidate[];
/** Paseo's editor. Every text file has it, so a text file always opens with some panel. */
export const sourceFilePanel = {
id: "builtin:source",
builtin: true,
files: "all",
fileKind: "text",
} as const satisfies FilePanelCandidate;
export type RenderingFilePanel = (typeof renderingPanels)[number];
export type SourceFilePanel = typeof sourceFilePanel;
/**
* Every panel a host offers, in resolver order: the built-ins that render a file, then plugin
* panels, then Source. Source is last so it is the default only when nothing else claims the file.
*/
export function filePanelCatalog<Plugin extends FilePanelCandidate>(
pluginPanels: readonly Plugin[],
): readonly (RenderingFilePanel | Plugin | SourceFilePanel)[] {
return [...renderingPanels, ...pluginPanels, sourceFilePanel];
export function isRenderedMarkdownFile(path: string): boolean {
return markdownPanel.files.extensions.some((extension) => extension === filePanelExtension(path));
}
@@ -1,88 +0,0 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
import { useFilePanelAssociations } from "../associations";
import { sourceFilePanel } from "../catalog";
import { useFilePanels, type FilePanel } from "../panels";
import { resolveFilePanel, type FilePanelFileKind } from "../resolve";
import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model";
import { getFilePanelChoice, registerFilePanelChoice, subscribeFilePanelChoices } from "./registry";
export type FilePanelChoice = FilePanelChoiceModel<FilePanel>;
/**
* The file tab's panel: resolves it from the catalog, the device associations and this tab's
* choice, and owns the picker and "Open with…" for it.
*/
export function useFilePanelChoice(input: {
serverId: string;
targetKey: string | null;
path: string;
/** The kind the file's stat reports, or null before the stat. */
fileKind: FilePanelFileKind | null;
/** The tab is on screen. */
presentable: boolean;
/** Identifies the tab's latest request to show a line, or null when it targets no line. */
lineNavigation: number | null;
}): { panel: FilePanel | null; choice: FilePanelChoice } {
const { targetKey, path, fileKind, presentable, lineNavigation } = input;
const { serverId, workspaceId, tabId } = usePaneContext();
const candidates = useFilePanels(input.serverId);
const { associations, remember } = useFilePanelAssociations();
const [choice] = useState(
() => new FilePanelChoiceModel<FilePanel>({ remember, linePanelId: sourceFilePanel.id }),
);
// From the subscribed snapshot: React Compiler caches reads off the stable model object.
const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey);
const resolution = useMemo(
() =>
fileKind
? resolveFilePanel({
file: { path, kind: fileKind },
candidates,
associations,
choice: chosenPanelId,
})
: null,
[associations, candidates, chosenPanelId, fileKind, path],
);
useEffect(() => {
choice.observe({ targetKey, resolution, presentable, lineNavigation });
}, [choice, lineNavigation, presentable, resolution, targetKey]);
useEffect(
() => registerFilePanelChoice({ serverId, workspaceId, tabId }, choice),
[choice, serverId, tabId, workspaceId],
);
return { panel: resolution?.panel ?? null, choice };
}
const NO_CHOICE_SNAPSHOT: FilePanelChoiceSnapshot<FilePanel> = {
targetKey: null,
chosenPanelId: null,
picker: null,
openWith: null,
};
const subscribeNothing = () => () => {};
const getNoChoiceSnapshot = () => NO_CHOICE_SNAPSHOT;
/** The choice of an open file tab, looked up by the tab's identity, for chrome outside its pane. */
export function useTabFilePanelChoice(identity: PanelInstanceIdentity): FilePanelChoice | null {
const { serverId, workspaceId, tabId } = identity;
const getSnapshot = useCallback(
() => getFilePanelChoice({ serverId, workspaceId, tabId }),
[serverId, tabId, workspaceId],
);
return useSyncExternalStore(subscribeFilePanelChoices, getSnapshot, getSnapshot);
}
export function useFilePanelChoiceSnapshot(
choice: FilePanelChoice | null,
): FilePanelChoiceSnapshot<FilePanel> {
return useSyncExternalStore(
choice?.subscribe ?? subscribeNothing,
choice?.getSnapshot ?? getNoChoiceSnapshot,
choice?.getSnapshot ?? getNoChoiceSnapshot,
);
}
@@ -1,203 +0,0 @@
import { describe, expect, it } from "vitest";
import { filePanelCatalog, sourceFilePanel } from "../catalog";
import { resolveFilePanel, type FilePanelCandidate } from "../resolve";
import { chosenPanelFor, FilePanelChoiceModel } from "./model";
const rich: FilePanelCandidate = {
id: "plugin:notes/rich",
builtin: false,
files: { extensions: ["md", "markdown"] },
};
const markdown = "builtin:markdown";
const source = sourceFilePanel.id;
/** A file tab: resolves against the shared associations and feeds the result to its model. */
function openTab(
associations: Record<string, string>,
candidates: FilePanelCandidate[] = [...filePanelCatalog([rich])],
options: { presentable?: boolean; path?: string; lineNavigation?: number | null } = {},
) {
const model = new FilePanelChoiceModel<FilePanelCandidate>({
remember(selectorKey, panelId) {
if (panelId === null) delete associations[selectorKey];
else associations[selectorKey] = panelId;
},
linePanelId: source,
});
let targetKey = `/repo:${options.path ?? "notes.md"}`;
let presentable = options.presentable ?? true;
let lineNavigation = options.lineNavigation ?? null;
const resolve = () =>
resolveFilePanel({
file: { path: targetKey.split(":")[1]!, kind: "text" },
candidates,
associations,
choice: chosenPanelFor(model.getSnapshot(), targetKey),
});
// Like the hook: observe, then render what the model's choice resolves to.
const render = () => {
model.observe({ targetKey, resolution: resolve(), presentable, lineNavigation });
return resolve().panel?.id ?? null;
};
render();
return {
model,
render,
panel: () => render(),
picker: () => model.getSnapshot().picker,
retarget(path: string) {
targetKey = `/repo:${path}`;
return render();
},
setPresentable(next: boolean) {
presentable = next;
return render();
},
navigateToLine(revision: number) {
lineNavigation = revision;
return render();
},
};
}
describe("FilePanelChoiceModel", () => {
it("asks once per tab open over the first candidate, and dismissing keeps it", () => {
const associations = {};
const tab = openTab(associations);
expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toMatchObject({
selectorKey: "ext:md",
selectedPanelId: markdown,
remember: false,
});
expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([
markdown,
rich.id,
source,
]);
tab.model.dismissPicker();
expect(tab.picker()).toBeNull();
expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toBeNull();
expect(associations).toEqual({});
expect(openTab(associations).picker()).not.toBeNull();
});
it("never asks between built-in panels, and switches between them", () => {
const tab = openTab({}, [...filePanelCatalog([])]);
expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toBeNull();
expect(tab.model.getSnapshot().openWith?.candidates.map((candidate) => candidate.id)).toEqual([
markdown,
source,
]);
tab.model.openWith(source);
expect(tab.panel()).toBe(source);
expect(tab.picker()).toBeNull();
});
it("opens a file only Source claims with Source and nothing to choose", () => {
const tab = openTab({}, [...filePanelCatalog([rich])], { path: "index.ts" });
expect(tab.panel()).toBe(source);
expect(tab.picker()).toBeNull();
expect(tab.model.getSnapshot().openWith).toBeNull();
});
it("remembers the choice when Don't ask me again is checked", () => {
const associations: Record<string, string> = {};
const tab = openTab(associations);
tab.model.selectInPicker(rich.id);
tab.model.setRemember(true);
tab.model.confirmPicker();
expect(tab.panel()).toBe(rich.id);
expect(associations).toEqual({ "ext:md": rich.id });
const next = openTab(associations);
expect(next.panel()).toBe(rich.id);
expect(next.picker()).toBeNull();
});
it("uses an unremembered choice for this tab only and asks again on the next open", () => {
const associations = {};
const tab = openTab(associations);
tab.model.selectInPicker(rich.id);
tab.model.confirmPicker();
expect(tab.panel()).toBe(rich.id);
expect(associations).toEqual({});
const next = openTab(associations);
expect(next.panel()).toBe(markdown);
expect(next.picker()).not.toBeNull();
});
it("does not ask with an association or while the tab is in the background", () => {
expect(openTab({ "ext:md": rich.id }).picker()).toBeNull();
expect(openTab({ "ext:md": source }).panel()).toBe(source);
const background = openTab({}, undefined, { presentable: false });
expect(background.picker()).toBeNull();
background.setPresentable(true);
expect(background.picker()).not.toBeNull();
});
it("opens Source for a line target without asking, and again on each new one", () => {
const tab = openTab({}, undefined, { lineNavigation: 1 });
expect(tab.panel()).toBe(source);
expect(tab.picker()).toBeNull();
tab.model.openWith(rich.id);
expect(tab.panel()).toBe(rich.id);
expect(tab.navigateToLine(1)).toBe(rich.id);
expect(tab.navigateToLine(2)).toBe(source);
});
it("opens Source for the same line target in a retargeted tab", () => {
const tab = openTab({}, undefined, { lineNavigation: 1 });
tab.model.openWith(markdown);
expect(tab.retarget("other.md")).toBe(source);
});
it("starts a new open when the tab is retargeted", () => {
const tab = openTab({});
tab.model.selectInPicker(rich.id);
tab.model.confirmPicker();
expect(tab.retarget("other.md")).toBe(markdown);
expect(tab.picker()).not.toBeNull();
});
it("switches with Open with and toggles Always use without moving the tab", () => {
const associations: Record<string, string> = {};
const tab = openTab(associations);
tab.model.dismissPicker();
expect(tab.model.getSnapshot().openWith).toMatchObject({
activePanelId: markdown,
remembered: false,
});
tab.model.openWith(rich.id);
expect(tab.panel()).toBe(rich.id);
tab.model.toggleAlwaysUse();
expect(associations).toEqual({ "ext:md": rich.id });
tab.render();
expect(tab.model.getSnapshot().openWith?.remembered).toBe(true);
tab.model.openWith(markdown);
expect(tab.panel()).toBe(markdown);
expect(tab.model.getSnapshot().openWith?.remembered).toBe(false);
tab.model.openWith(rich.id);
tab.render();
tab.model.toggleAlwaysUse();
expect(associations).toEqual({});
expect(tab.panel()).toBe(rich.id);
});
it("asks when a plugin panel arrives after the file first rendered", () => {
const candidates: FilePanelCandidate[] = [...filePanelCatalog([])];
const tab = openTab({}, candidates);
expect(tab.picker()).toBeNull();
candidates.splice(1, 0, rich);
tab.render();
expect(tab.picker()).not.toBeNull();
});
});
@@ -1,195 +0,0 @@
import { asksForFilePanel, type FilePanelCandidate, type FilePanelResolution } from "../resolve";
/** What the file tab currently resolves; `resolution` is null until the file has been read. */
export interface FilePanelChoiceSource<Candidate extends FilePanelCandidate> {
targetKey: string | null;
resolution: FilePanelResolution<Candidate> | null;
/** The tab is on screen, so a picker shown now is seen over the panel it chooses for. */
presentable: boolean;
/** Identifies the tab's latest request to show a line, or null when it targets no line. */
lineNavigation: number | null;
}
export interface FilePanelPickerState<Candidate> {
selectorKey: string;
candidates: readonly Candidate[];
selectedPanelId: string;
remember: boolean;
}
export interface FilePanelOpenWithState<Candidate> {
selectorKey: string;
candidates: readonly Candidate[];
activePanelId: string;
/** The active panel is the one remembered for `selectorKey`. */
remembered: boolean;
}
export interface FilePanelChoiceSnapshot<Candidate> {
targetKey: string | null;
/** The panel picked for this tab open. Resolution prefers it over the association. */
chosenPanelId: string | null;
picker: FilePanelPickerState<Candidate> | null;
/** Present only when the file has more than one candidate; drives the bar's panel switcher. */
openWith: FilePanelOpenWithState<Candidate> | null;
}
export interface FilePanelChoiceDeps {
/** Writes (or clears, with null) the device-wide association for a selector key. */
remember(selectorKey: string, panelId: string | null): void;
/** The panel a line target opens: Source, the panel that shows lines. */
linePanelId: string;
}
/** The tab choice that applies to `targetKey`; a choice never outlives its open. */
export function chosenPanelFor(
snapshot: FilePanelChoiceSnapshot<unknown>,
targetKey: string | null,
): string | null {
return snapshot.targetKey === targetKey ? snapshot.chosenPanelId : null;
}
const EMPTY: FilePanelChoiceSnapshot<never> = {
targetKey: null,
chosenPanelId: null,
picker: null,
openWith: null,
};
/**
* Which file panel one file tab shows, and whether to ask.
*
* A tab open is one target in one tab: the picker is offered at most once per open, the first
* time the tab is on screen with a plugin panel among several candidates and nothing remembered.
* Built-in panels alone never ask. A line target opens the line panel the way "Open with…" does.
* Retargeting the tab starts a new open. Choices made here apply to this tab; only "Don't ask me
* again" and "Always use" write the association every later open reads.
*/
export class FilePanelChoiceModel<Candidate extends FilePanelCandidate> {
private readonly listeners = new Set<() => void>();
private snapshot: FilePanelChoiceSnapshot<Candidate> = EMPTY;
private asked = false;
private openedLineNavigation: number | null = null;
constructor(private readonly deps: FilePanelChoiceDeps) {}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FilePanelChoiceSnapshot<Candidate> => this.snapshot;
observe(source: FilePanelChoiceSource<Candidate>): void {
let current = this.snapshot;
if (source.targetKey !== current.targetKey) {
this.asked = false;
this.openedLineNavigation = null;
current = { ...EMPTY, targetKey: source.targetKey };
}
if (source.lineNavigation !== null && source.lineNavigation !== this.openedLineNavigation) {
this.openedLineNavigation = source.lineNavigation;
current = { ...current, chosenPanelId: this.deps.linePanelId, picker: null };
}
const openWith = stableOpenWith(current.openWith, openWithState(source.resolution));
let picker = openWith ? current.picker : null;
// Decided once per open, the first time there is something to ask about on screen. An
// association forgotten while the tab stays open is asked about on the next open.
if (!this.asked && source.presentable && openWith && asksForFilePanel(openWith.candidates)) {
this.asked = true;
const remembered = Boolean(source.resolution?.associatedPanelId);
if (!remembered && current.chosenPanelId === null) {
picker = {
selectorKey: openWith.selectorKey,
candidates: openWith.candidates,
selectedPanelId: openWith.activePanelId,
remember: false,
};
}
}
this.publish({ ...current, openWith, picker });
}
selectInPicker(panelId: string): void {
const picker = this.snapshot.picker;
if (!picker) return;
this.publish({ ...this.snapshot, picker: { ...picker, selectedPanelId: panelId } });
}
setRemember(remember: boolean): void {
const picker = this.snapshot.picker;
if (!picker) return;
this.publish({ ...this.snapshot, picker: { ...picker, remember } });
}
confirmPicker(): void {
const picker = this.snapshot.picker;
if (!picker) return;
if (picker.remember) this.deps.remember(picker.selectorKey, picker.selectedPanelId);
this.publish({ ...this.snapshot, chosenPanelId: picker.selectedPanelId, picker: null });
}
/** Keeps whatever the tab shows now; the next open asks again. */
dismissPicker(): void {
if (!this.snapshot.picker) return;
this.publish({ ...this.snapshot, picker: null });
}
openWith(panelId: string): void {
this.publish({ ...this.snapshot, chosenPanelId: panelId, picker: null });
}
/** Remembers the active panel, or forgets it when it already is the remembered one. */
toggleAlwaysUse(): void {
const openWith = this.snapshot.openWith;
if (!openWith) return;
if (!openWith.remembered) {
this.deps.remember(openWith.selectorKey, openWith.activePanelId);
return;
}
// Forgetting must not move the tab off the panel it is showing.
this.publish({ ...this.snapshot, chosenPanelId: openWith.activePanelId });
this.deps.remember(openWith.selectorKey, null);
}
private publish(next: FilePanelChoiceSnapshot<Candidate>): void {
const current = this.snapshot;
if (
current.targetKey === next.targetKey &&
current.chosenPanelId === next.chosenPanelId &&
current.picker === next.picker &&
current.openWith === next.openWith
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) listener();
}
}
function openWithState<Candidate extends FilePanelCandidate>(
resolution: FilePanelResolution<Candidate> | null,
): FilePanelOpenWithState<Candidate> | null {
if (!resolution?.panel || resolution.candidates.length < 2) return null;
return {
selectorKey: resolution.selectorKey,
candidates: resolution.candidates,
activePanelId: resolution.panel.id,
remembered: resolution.associatedPanelId === resolution.panel.id,
};
}
/** Keeps the published object when nothing in it changed, so subscribers do not re-render. */
function stableOpenWith<Candidate>(
current: FilePanelOpenWithState<Candidate> | null,
next: FilePanelOpenWithState<Candidate> | null,
): FilePanelOpenWithState<Candidate> | null {
if (!current || !next) return next;
const same =
current.selectorKey === next.selectorKey &&
current.activePanelId === next.activePanelId &&
current.remembered === next.remembered &&
current.candidates.length === next.candidates.length &&
current.candidates.every((candidate, index) => candidate === next.candidates[index]);
return same ? current : next;
}
@@ -1,175 +0,0 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { Ellipsis } from "lucide-react-native";
import {
MenuItem,
MenuSeparator,
MenuSubTrigger,
type MenuPageDefinition,
} from "@/components/ui/menu";
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
import { PluginIconView } from "@/plugins/icons";
import type { Theme } from "@/styles/theme";
import type { FilePanel } from "../panels";
import { useFilePanelChoiceSnapshot, useTabFilePanelChoice, type FilePanelChoice } from "./hook";
import { filePanelAlwaysUseLabel, filePanelIcon, filePanelOptionLabel } from "./presentation";
const OPEN_WITH_PAGE_ID = "filePanelOpenWith";
const MENU_ICON_SIZE = 16;
const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedPanelIcon = withUnistyles(PluginIconView);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const NO_PAGES: readonly MenuPageDefinition[] = [];
export interface FilePanelOpenWithMenu {
/** The "Open with…" row, or null when the file has one panel or none. */
trigger: ReactElement | null;
/** The page the row opens; pass to the menu surface. */
pages: readonly MenuPageDefinition[];
}
/** "Open with…" for a file tab's context menu, found by the tab's identity. */
export function useFilePanelOpenWithMenu(
identity: PanelInstanceIdentity,
testIDBase: string,
): FilePanelOpenWithMenu {
return useOpenWithMenu(useTabFilePanelChoice(identity), testIDBase);
}
/**
* The file bar's overflow: remembering the panel the bar's switcher shows. Renders nothing unless
* the file has more than one panel.
*/
export function FilePanelBarMenu({ choice }: { choice: FilePanelChoice }) {
const { t } = useTranslation();
const compact = useIsCompactFormFactor();
const { openWith } = useFilePanelChoiceSnapshot(choice);
if (!openWith) return null;
const label = t("panels.file.filePanels.actions");
return (
<DropdownMenu compactMode="sheet">
<ToolbarButton kind="menu" label={label} compact={compact} testID="file-panel-bar-menu">
<ThemedEllipsis
size={paneContentToolbarIconSize(compact)}
uniProps={extraMutedIconColorMapping}
/>
</ToolbarButton>
<DropdownMenuContent
align="end"
width={240}
sheetTitle={label}
testID="file-panel-bar-menu-surface"
>
<AlwaysUseItem
choice={choice}
label={filePanelAlwaysUseLabel(t, openWith.selectorKey)}
selected={openWith.remembered}
testID="file-panel-bar-menu-always-use"
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
function useOpenWithMenu(
choice: FilePanelChoice | null,
testIDBase: string,
): FilePanelOpenWithMenu {
const { t } = useTranslation();
const { openWith } = useFilePanelChoiceSnapshot(choice);
return useMemo(() => {
if (!choice || !openWith) return { trigger: null, pages: NO_PAGES };
const title = t("panels.file.filePanels.openWith");
return {
trigger: (
<MenuSubTrigger id={OPEN_WITH_PAGE_ID} testID={`${testIDBase}-open-with`}>
{title}
</MenuSubTrigger>
),
pages: [
{
id: OPEN_WITH_PAGE_ID,
title,
content: (
<>
{openWith.candidates.map((panel) => (
<OpenWithItem
key={panel.id}
choice={choice}
panel={panel}
label={filePanelOptionLabel(t, panel)}
selected={panel.id === openWith.activePanelId}
testID={`${testIDBase}-open-with-${panel.id}`}
/>
))}
<MenuSeparator />
<AlwaysUseItem
choice={choice}
label={filePanelAlwaysUseLabel(t, openWith.selectorKey)}
selected={openWith.remembered}
testID={`${testIDBase}-always-use`}
/>
</>
),
},
],
};
}, [choice, openWith, t, testIDBase]);
}
function OpenWithItem({
choice,
panel,
label,
selected,
testID,
}: {
choice: FilePanelChoice;
panel: FilePanel;
label: string;
selected: boolean;
testID: string;
}) {
const handleSelect = useCallback(() => choice.openWith(panel.id), [choice, panel.id]);
const leading = useMemo(
() => (
<ThemedPanelIcon
icon={filePanelIcon(panel)}
size={MENU_ICON_SIZE}
uniProps={mutedIconMapping}
/>
),
[panel],
);
return (
<MenuItem selected={selected} leading={leading} onSelect={handleSelect} testID={testID}>
{label}
</MenuItem>
);
}
function AlwaysUseItem({
choice,
label,
selected,
testID,
}: {
choice: FilePanelChoice;
label: string;
selected: boolean;
testID: string;
}) {
const handleSelect = useCallback(() => choice.toggleAlwaysUse(), [choice]);
return (
<MenuItem selected={selected} onSelect={handleSelect} testID={testID}>
{label}
</MenuItem>
);
}
@@ -1,233 +0,0 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PressHighlight } from "@/components/ui/press-highlight";
import { PluginIconView } from "@/plugins/icons";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import type { FilePanel } from "../panels";
import type { FilePanelPickerState } from "./model";
import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook";
import { filePanelIcon, filePanelOptionLabel, filePanelPickerTitle } from "./presentation";
const ThemedCheck = withUnistyles(Check);
const ThemedPanelIcon = withUnistyles(PluginIconView);
const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** The first-open question for a file with several panels, over the panel already showing. */
export function FilePanelPicker({ choice }: { choice: FilePanelChoice }) {
const { t } = useTranslation();
const { picker } = useFilePanelChoiceSnapshot(choice);
// The sheet animates out after the model has closed the picker; keep drawing what it asked.
const [shown, setShown] = useState(picker);
if (picker && picker !== shown) setShown(picker);
const content = picker ?? shown;
const dismiss = useCallback(() => choice.dismissPicker(), [choice]);
const confirm = useCallback(() => choice.confirmPicker(), [choice]);
const toggleRemember = useCallback(
() => choice.setRemember(!choice.getSnapshot().picker?.remember),
[choice],
);
const header = useMemo<SheetHeader>(
() => ({ title: content ? filePanelPickerTitle(t, content.selectorKey) : "" }),
[content, t],
);
const footer = useMemo(
() => (
<Button
style={styles.footerButton}
variant="default"
onPress={confirm}
testID="file-panel-picker-use"
>
{t("panels.file.filePanels.use")}
</Button>
),
[confirm, t],
);
return (
<AdaptiveModalSheet
header={header}
visible={picker !== null}
onClose={dismiss}
footer={footer}
snapPoints={PICKER_SNAP_POINTS}
desktopMaxWidth={420}
testID="file-panel-picker"
>
{content ? (
<PickerBody choice={choice} picker={content} onToggleRemember={toggleRemember} />
) : null}
</AdaptiveModalSheet>
);
}
const PICKER_SNAP_POINTS = ["60%", "90%"];
function PickerBody({
choice,
picker,
onToggleRemember,
}: {
choice: FilePanelChoice;
picker: FilePanelPickerState<FilePanel>;
onToggleRemember: () => void;
}) {
const { t } = useTranslation();
const select = useCallback((panelId: string) => choice.selectInPicker(panelId), [choice]);
return (
<>
<View style={settingsStyles.card} accessibilityRole="radiogroup">
{picker.candidates.map((panel, index) => (
<PanelRadioRow
key={panel.id}
panel={panel}
label={filePanelOptionLabel(t, panel)}
selected={panel.id === picker.selectedPanelId}
withBorder={index > 0}
onSelect={select}
/>
))}
</View>
<CheckboxRow
label={t("panels.file.filePanels.dontAskAgain")}
checked={picker.remember}
onToggle={onToggleRemember}
/>
</>
);
}
function PanelRadioRow({
panel,
label,
selected,
withBorder,
onSelect,
}: {
panel: FilePanel;
label: string;
selected: boolean;
withBorder: boolean;
onSelect: (panelId: string) => void;
}) {
const handlePress = useCallback(() => onSelect(panel.id), [onSelect, panel.id]);
const accessibilityState = useMemo(() => ({ checked: selected }), [selected]);
const rowStyle = useMemo(
() => [settingsStyles.row, withBorder ? settingsStyles.rowBorder : null, styles.optionRow],
[withBorder],
);
return (
<PressHighlight
style={rowStyle}
onPress={handlePress}
accessibilityRole="radio"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-checked={selected}
>
<View style={selected ? styles.radioSelected : styles.radio}>
{selected ? <View style={styles.radioDot} /> : null}
</View>
<ThemedPanelIcon icon={filePanelIcon(panel)} size={16} uniProps={mutedIconMapping} />
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{label}
</Text>
</PressHighlight>
);
}
function CheckboxRow({
label,
checked,
onToggle,
}: {
label: string;
checked: boolean;
onToggle: () => void;
}) {
const accessibilityState = useMemo(() => ({ checked }), [checked]);
return (
<PressHighlight
style={styles.checkboxRow}
onPress={onToggle}
accessibilityRole="checkbox"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-checked={checked}
testID="file-panel-picker-remember"
>
<View style={checked ? styles.checkboxChecked : styles.checkbox}>
{checked ? <ThemedCheck size={14} uniProps={checkedIconMapping} /> : null}
</View>
<Text style={settingsStyles.rowTitle}>{label}</Text>
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
footerButton: {
flex: 1,
},
optionRow: {
justifyContent: "flex-start",
gap: theme.spacing[3],
},
radio: {
width: 18,
height: 18,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
radioSelected: {
width: 18,
height: 18,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
radioDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
},
checkboxRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
},
checkbox: {
width: 18,
height: 18,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
width: 18,
height: 18,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.accent,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
}));
@@ -1,52 +0,0 @@
import type { TFunction } from "i18next";
import type { PluginIcon } from "@getpaseo/plugin/client";
import type { FilePanel } from "../panels";
import { parseFilePanelSelectorKey } from "../resolve";
const BUILTIN_PRESENTATION = {
"builtin:markdown": { title: "markdown", icon: "FileText" },
"builtin:html": { title: "html", icon: "FileCode" },
"builtin:image": { title: "image", icon: "Image" },
"builtin:source": { title: "source", icon: "Code" },
} as const;
export function filePanelIcon(panel: FilePanel): PluginIcon {
return panel.builtin ? BUILTIN_PRESENTATION[panel.id].icon : panel.contribution.icon;
}
/** "Markdown", "Rich Markdown": the panel switcher's label. */
export function filePanelTitle(t: TFunction, panel: FilePanel): string {
return panel.builtin
? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`)
: panel.contribution.title;
}
/** "Markdown — Paseo", "Rich Markdown — markdown-panel". */
export function filePanelOptionLabel(t: TFunction, panel: FilePanel): string {
return t("panels.file.filePanels.option", {
title: filePanelTitle(t, panel),
owner: panel.builtin ? t("panels.file.filePanels.builtin.owner") : panel.plugin.id,
});
}
/** ".md" for an extension key, "Dockerfile" for a filename key. */
export function filePanelSelectorLabel(selectorKey: string): string {
const selector = parseFilePanelSelectorKey(selectorKey);
return selector.kind === "name" ? selector.value : `.${selector.value}`;
}
/** "Open .md files with" for an extension key, "Open Dockerfile with" for a filename key. */
export function filePanelPickerTitle(t: TFunction, selectorKey: string): string {
const selector = parseFilePanelSelectorKey(selectorKey);
return selector.kind === "name"
? t("panels.file.filePanels.pickerTitleFilename", { filename: selector.value })
: t("panels.file.filePanels.pickerTitleExtension", { extension: selector.value });
}
/** "Always use for .md", "Always use for Dockerfile". */
export function filePanelAlwaysUseLabel(t: TFunction, selectorKey: string): string {
const selector = parseFilePanelSelectorKey(selectorKey);
return selector.kind === "name"
? t("panels.file.filePanels.alwaysUseFilename", { filename: selector.value })
: t("panels.file.filePanels.alwaysUseExtension", { extension: selector.value });
}
@@ -1,42 +0,0 @@
import {
buildPanelInstanceKey,
type PanelInstanceIdentity,
} from "@/panels/panel-instance-attributes";
import type { FilePanel } from "../panels";
import type { FilePanelChoiceModel } from "./model";
/**
* The open file tabs' choice models, so tab chrome outside the pane (the tab's context menu)
* reads and drives the same choice the pane renders.
*/
const models = new Map<string, FilePanelChoiceModel<FilePanel>>();
const listeners = new Set<() => void>();
export function registerFilePanelChoice(
identity: PanelInstanceIdentity,
model: FilePanelChoiceModel<FilePanel>,
): () => void {
const key = buildPanelInstanceKey(identity);
models.set(key, model);
notify();
return () => {
if (models.get(key) !== model) return;
models.delete(key);
notify();
};
}
export function getFilePanelChoice(
identity: PanelInstanceIdentity,
): FilePanelChoiceModel<FilePanel> | null {
return models.get(buildPanelInstanceKey(identity)) ?? null;
}
export function subscribeFilePanelChoices(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function notify(): void {
for (const listener of listeners) listener();
}
@@ -1,31 +0,0 @@
import { describe, expect, it } from "vitest";
import type { FilePanelOpenWithState } from "./model";
import { filePanelSwitcherState } from "./switcher-layout";
function openWith(count: number): FilePanelOpenWithState<{ id: string }> {
const candidates = Array.from({ length: count }, (_, index) => ({ id: `panel-${index}` }));
return { selectorKey: "ext:md", candidates, activePanelId: "panel-1", remembered: false };
}
describe("filePanelSwitcherState", () => {
it("shows nothing when the file has one panel or none", () => {
expect(filePanelSwitcherState(null, { compact: false })).toBeNull();
});
it("sets up to three panels side by side and more behind a menu", () => {
const wide = { compact: false };
expect(filePanelSwitcherState(openWith(2), wide)?.layout).toBe("segmented");
expect(filePanelSwitcherState(openWith(3), wide)?.layout).toBe("segmented");
expect(filePanelSwitcherState(openWith(4), wide)).toMatchObject({
layout: "menu",
activePanelId: "panel-1",
candidates: openWith(4).candidates,
});
});
it("sets two panels side by side on a compact screen and more behind a menu", () => {
const compact = { compact: true };
expect(filePanelSwitcherState(openWith(2), compact)?.layout).toBe("segmented");
expect(filePanelSwitcherState(openWith(3), compact)?.layout).toBe("menu");
});
});
@@ -1,24 +0,0 @@
import type { FilePanelOpenWithState } from "./model";
/** Up to this many panels sit side by side in the bar; more go behind a menu. */
const SEGMENTED_PANEL_LIMIT = { wide: 3, compact: 2 };
export interface FilePanelSwitcherState<Candidate> {
layout: "segmented" | "menu";
candidates: readonly Candidate[];
activePanelId: string;
}
/** The bar's panel switcher for a tab's choice; null when the file has one panel or none. */
export function filePanelSwitcherState<Candidate>(
openWith: FilePanelOpenWithState<Candidate> | null,
{ compact }: { compact: boolean },
): FilePanelSwitcherState<Candidate> | null {
if (!openWith) return null;
const limit = compact ? SEGMENTED_PANEL_LIMIT.compact : SEGMENTED_PANEL_LIMIT.wide;
return {
layout: openWith.candidates.length > limit ? "menu" : "segmented",
candidates: openWith.candidates,
activePanelId: openWith.activePanelId,
};
}
@@ -1,138 +0,0 @@
import { useCallback, useMemo } from "react";
import { Text } from "react-native";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { MenuItem } from "@/components/ui/menu";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
ToolbarLabelTriggerIcon,
isToolbarLabelTriggerHighlighted,
toolbarLabelTriggerStyle,
toolbarLabelTriggerTextStyle,
} from "@/components/ui/toolbar-label-trigger";
import { useIsCompactFormFactor } from "@/constants/layout";
import { PluginIconView } from "@/plugins/icons";
import type { Theme } from "@/styles/theme";
import type { FilePanel } from "../panels";
import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook";
import { filePanelIcon, filePanelTitle } from "./presentation";
import { filePanelSwitcherState, type FilePanelSwitcherState } from "./switcher-layout";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedPanelIcon = withUnistyles(PluginIconView);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** The file bar's panel switcher. Renders nothing unless the file has more than one panel. */
export function FilePanelSwitcher({ choice }: { choice: FilePanelChoice }) {
const { openWith } = useFilePanelChoiceSnapshot(choice);
const compact = useIsCompactFormFactor();
const switcher = filePanelSwitcherState(openWith, { compact });
const select = useCallback((panelId: string) => choice.openWith(panelId), [choice]);
if (!switcher) return null;
return switcher.layout === "segmented" ? (
<SegmentedSwitcher switcher={switcher} onSelect={select} />
) : (
<MenuSwitcher switcher={switcher} onSelect={select} />
);
}
interface SwitcherProps {
switcher: FilePanelSwitcherState<FilePanel>;
onSelect(panelId: string): void;
}
function SegmentedSwitcher({ switcher, onSelect }: SwitcherProps) {
const { t } = useTranslation();
const options = useMemo(
() =>
switcher.candidates.map((panel) => ({
value: panel.id,
label: filePanelTitle(t, panel),
testID: `file-panel-switcher-${panel.id}`,
})),
[switcher.candidates, t],
);
return (
<SegmentedControl
size="xs"
value={switcher.activePanelId}
onValueChange={onSelect}
options={options}
testID="file-panel-switcher"
/>
);
}
function MenuSwitcher({ switcher, onSelect }: SwitcherProps) {
const { t } = useTranslation();
const active = switcher.candidates.find((panel) => panel.id === switcher.activePanelId);
const activeTitle = active ? filePanelTitle(t, active) : "";
return (
<DropdownMenu>
<DropdownMenuTrigger
style={toolbarLabelTriggerStyle}
accessibilityRole="button"
accessibilityLabel={t("panels.file.filePanels.openWith")}
testID="file-panel-switcher"
>
{(state) => (
<>
<Text
style={toolbarLabelTriggerTextStyle(isToolbarLabelTriggerHighlighted(state))}
numberOfLines={1}
>
{activeTitle}
</Text>
<ToolbarLabelTriggerIcon>
<ThemedChevronDown size={12} uniProps={extraMutedIconColorMapping} />
</ToolbarLabelTriggerIcon>
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="file-panel-switcher-menu">
{switcher.candidates.map((panel) => (
<SwitcherMenuItem
key={panel.id}
panel={panel}
selected={panel.id === switcher.activePanelId}
onSelect={onSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function SwitcherMenuItem({
panel,
selected,
onSelect,
}: {
panel: FilePanel;
selected: boolean;
onSelect(panelId: string): void;
}) {
const { t } = useTranslation();
const handleSelect = useCallback(() => onSelect(panel.id), [onSelect, panel.id]);
const leading = useMemo(
() => <ThemedPanelIcon icon={filePanelIcon(panel)} size={16} uniProps={mutedIconMapping} />,
[panel],
);
return (
<MenuItem
selected={selected}
leading={leading}
onSelect={handleSelect}
testID={`file-panel-switcher-${panel.id}`}
>
{filePanelTitle(t, panel)}
</MenuItem>
);
}
@@ -1,39 +0,0 @@
import { useCallback, useMemo, useRef } from "react";
import { WebView } from "react-native-webview";
const frameStyle = { flex: 1, backgroundColor: "white" };
const originWhitelist = ["*"];
export function DocumentFrame({ url, testID }: { url: string; testID?: string }) {
const source = useMemo(() => ({ uri: url }), [url]);
const loaded = useRef<string | null>(null);
const allowDocument = useCallback(
(request: { url: string }) => {
if (!url.startsWith("file:") || request.url !== url || loaded.current === url) return false;
loaded.current = url;
return true;
},
[url],
);
if (!url.startsWith("file:")) return null;
return (
<WebView
testID={testID}
style={frameStyle}
source={source}
originWhitelist={originWhitelist}
onShouldStartLoadWithRequest={allowDocument}
javaScriptEnabled={false}
setSupportMultipleWindows={false}
javaScriptCanOpenWindowsAutomatically={false}
domStorageEnabled={false}
thirdPartyCookiesEnabled={false}
cacheEnabled={false}
incognito
allowFileAccess
allowFileAccessFromFileURLs={false}
allowUniversalAccessFromFileURLs={false}
allowingReadAccessToURL={url}
/>
);
}
@@ -1,41 +0,0 @@
import { useEffect, useState } from "react";
import { documentFrameSandbox } from "./sandbox";
const frameStyle = { flex: 1, minHeight: 0, border: "none", backgroundColor: "white" } as const;
interface ResolvedDocument {
url: string;
sandbox: string | null;
}
export function DocumentFrame({ url, testID }: { url: string; testID?: string }) {
const [document, setDocument] = useState<ResolvedDocument | null>(null);
useEffect(() => {
if (!url.startsWith("blob:")) return;
const lifetime = new AbortController();
void fetch(url, { signal: lifetime.signal })
.then(async (response) => {
const sandbox = documentFrameSandbox(response.headers.get("content-type"));
await response.body?.cancel();
if (!lifetime.signal.aborted) setDocument({ url, sandbox });
return undefined;
})
.catch(() => {
if (!lifetime.signal.aborted) setDocument({ url, sandbox: "" });
});
return () => lifetime.abort();
}, [url]);
if (!url.startsWith("blob:") || document?.url !== url) return null;
return (
<iframe
title="Document preview"
data-testid={testID}
src={url}
sandbox={document.sandbox ?? undefined}
referrerPolicy="no-referrer"
style={frameStyle}
/>
);
}
@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import { documentFrameSandbox } from "./sandbox";
describe("document frame sandbox", () => {
it("lets the browser's PDF viewer render PDF documents", () => {
expect(documentFrameSandbox("application/pdf")).toBeNull();
expect(documentFrameSandbox("Application/PDF; charset=binary")).toBeNull();
});
it.each(["text/html", "application/octet-stream", "unknown", "", null, undefined])(
"keeps %s fully sandboxed",
(mediaType) => {
expect(documentFrameSandbox(mediaType)).toBe("");
},
);
});
@@ -1,9 +0,0 @@
/**
* Chromium refuses to run its PDF viewer in a sandboxed frame, regardless of tokens.
* The viewer is a browser component, not page script. The host sets the blob's type from
* the daemon's MIME type, so HTML bytes labeled PDF fail to parse in the viewer rather
* than executing. HTML and every other media type retain the full sandbox.
*/
export function documentFrameSandbox(mediaType: string | null | undefined): string | null {
return mediaType?.split(";")[0].trim().toLowerCase() === "application/pdf" ? null : "";
}
@@ -1,115 +0,0 @@
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
import {
persistAttachmentFromBytes,
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import type { FileKind, FileObservation, FileSession } from "./handle";
export interface DaemonFileFeatures {
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: boolean;
// COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20.
fileByteWrites: boolean;
}
/** A file session over a daemon connection. */
export function createDaemonFileSession(
client: DaemonClient,
features: DaemonFileFeatures,
): FileSession {
return {
async observe(target, onObservation) {
if (!features.workspaceFileEditing) {
// COMPAT(workspaceFileEditing): the host cannot observe files; its read is the stat.
return {
initial: statFromRead(await client.readFile(target.cwd, target.path)),
unsubscribe() {},
};
}
let kind: { kind: FileKind; mimeType: string } | null = null;
const classify = (version: FileVersion): FileObservation => {
if (version.status !== "ready") return version;
if (version.kind && version.mimeType) {
kind = { kind: version.kind, mimeType: version.mimeType };
}
// COMPAT(fileVersionKind): added in v0.13.0, remove after 2027-04-20. Older daemons
// classify a file only when reading it; the first read's kind stands for later versions.
if (!kind) return { ...version, status: "error", error: "The file has no kind." };
return { ...version, ...kind };
};
const subscription = await client.subscribeFile(target, (version) =>
onObservation(classify(version)),
);
const initial = subscription.initial;
if (initial.status === "ready" && !initial.kind) {
// COMPAT(fileVersionKind): see above.
const read = statFromRead(await client.readFile(target.cwd, target.path));
kind = { kind: read.kind, mimeType: read.mimeType };
}
return {
initial: classify(initial),
unsubscribe() {
void subscription.unsubscribe();
},
};
},
async read(target) {
return client.readFile(target.cwd, target.path);
},
async write({ target, content, ...expected }) {
if (!features.workspaceFileEditing) throw new Error("Update the host to edit files.");
if (typeof content === "string") {
return client.writeFile({ ...target, content, ...expected });
}
if (!features.fileByteWrites) throw new Error("Update the host to write binary files.");
return client.writeFile({
...target,
content: bytesToBase64(content),
contentEncoding: "base64",
...expected,
});
},
async createUrl({ target, bytes, mimeType }) {
const attachment = await persistAttachmentFromBytes({
id: createPreviewAttachmentId({
mimeType,
path: `${target.cwd}/${target.path}`,
contentLength: bytes.byteLength,
contentKey: String(Date.now()),
}),
bytes,
mimeType,
fileName: getFileNameFromPath(target.path),
});
const url = await resolveAttachmentPreviewUrl(attachment);
return {
url,
release() {
void releaseAttachmentPreviewUrl({ attachment, url });
},
};
},
};
}
function statFromRead(file: FileReadResult): Extract<FileObservation, { status: "ready" }> {
return {
status: "ready",
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
kind: file.kind,
mimeType: file.mime,
};
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return btoa(binary);
}
@@ -1,179 +0,0 @@
import { PluginFileConflictError, type PluginFileEvent } from "@getpaseo/plugin/client";
import { describe, expect, it } from "vitest";
import { FileHandle } from "./handle";
import { InMemoryFileSession } from "./testing";
async function openFile(content: string | Uint8Array = "first\n") {
const session = new InMemoryFileSession();
session.put("notes.md", content);
const handle = new FileHandle(session, { cwd: "/repo", path: "notes.md" }, "/repo/notes.md");
handle.setObserving(true);
await settle();
const events: PluginFileEvent[] = [];
handle.file.watch((event) => events.push(event));
return { session, handle, file: handle.file, events };
}
function settle(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
describe("FileHandle", () => {
it("describes the file from its stat before anything reads it", async () => {
const { handle, file } = await openFile("# Notes\n");
expect(handle.getSnapshot()).toMatchObject({ status: "ready", kind: "text", size: 8 });
expect([file.path, file.name, file.extension, file.mimeType, file.size]).toEqual([
"/repo/notes.md",
"notes.md",
"md",
"text/plain",
8,
]);
});
it("writes over the version it read and refreshes the stat", async () => {
const { session, file, events } = await openFile();
expect(await file.readText()).toBe("first\n");
await file.writeText("second, longer\n");
expect(session.text("notes.md")).toBe("second, longer\n");
expect(file.size).toBe(15);
expect(events).toEqual([]);
});
it("rejects a write after an external change unless forced", async () => {
const { session, file, events } = await openFile();
await file.readText();
session.put("notes.md", "external\n");
await expect(file.writeText("mine\n")).rejects.toBeInstanceOf(PluginFileConflictError);
expect(session.text("notes.md")).toBe("external\n");
await file.writeText("mine\n", { force: true });
expect(session.text("notes.md")).toBe("mine\n");
expect(events).toEqual(["changed"]);
});
it("forces over a change it has not observed yet", async () => {
const { session, handle, file } = await openFile();
await file.readText();
handle.setObserving(false);
session.put("notes.md", "unobserved\n");
await file.writeText("mine\n", { force: true });
expect(session.text("notes.md")).toBe("mine\n");
});
it("keeps a byte order mark it read", async () => {
const { session, file } = await openFile(
new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode("bom\r\n")]),
);
expect(await file.readText()).toBe("bom\r\n");
await file.writeText("saved\r\n");
expect(session.text("notes.md")).toBe("saved\r\n");
});
it("tells watchers about each external version once, and about deletion", async () => {
const { session, file, events } = await openFile();
session.put("notes.md", "external\n");
await file.readText();
session.remove("notes.md");
session.remove("notes.md");
await settle();
session.put("notes.md", "restored\n");
expect(events).toEqual(["changed", "deleted", "changed"]);
});
it("keeps the stat of a deleted file, so its panel stays mounted", async () => {
const { session, handle } = await openFile();
session.remove("notes.md");
await settle();
expect(handle.getSnapshot()).toMatchObject({ status: "ready", kind: "text" });
});
it("does not report a deletion when the file is back before a read confirms it", async () => {
const { session, events } = await openFile();
session.remove("notes.md");
session.put("notes.md", "first\n");
await settle();
expect(events).toEqual(["changed"]);
});
it("reports what changed while the tab was off screen", async () => {
const { session, handle, events } = await openFile();
handle.setObserving(false);
session.put("notes.md", "while hidden\n");
expect(events).toEqual([]);
handle.setObserving(true);
await settle();
expect(events).toEqual(["changed"]);
expect(session.observerCount()).toBe(1);
});
it("reads after its own pending writes", async () => {
const { file } = await openFile();
await file.readText();
const write = file.writeText("flushed\n");
const read = file.readText();
await write;
expect(await read).toBe("flushed\n");
});
it("holds writes while the tab asks to discard them, and drops them when it closes", async () => {
const { session, handle, file } = await openFile();
await file.readText();
const resume = handle.holdWrites();
const kept = file.writeText("kept\n");
await settle();
expect(session.text("notes.md")).toBe("first\n");
resume();
await kept;
expect(session.text("notes.md")).toBe("kept\n");
handle.holdWrites();
const dropped = file.writeText("dropped\n");
handle.dispose();
await expect(dropped).rejects.toThrow("The file tab was closed.");
expect(session.text("notes.md")).toBe("kept\n");
});
it("owns one URL per version and releases it when replaced or closed", async () => {
const { session, handle, file } = await openFile();
const first = await file.url();
expect(await file.url()).toBe(first);
session.put("notes.md", "changed\n");
const second = await file.url();
expect(second).not.toBe(first);
expect(session.createdUrls).toEqual([first, second]);
expect(session.releasedUrls).toEqual([first]);
handle.dispose();
expect(session.releasedUrls).toEqual([first, second]);
});
it("writes bytes", async () => {
const { session, file } = await openFile();
await file.readBytes();
await file.writeBytes(new Uint8Array([1, 2, 3]));
expect(session.writes).toEqual(["bytes:3"]);
});
});
-413
View File
@@ -1,413 +0,0 @@
import {
PluginFileConflictError,
type PluginFile,
type PluginFileEvent,
type PluginFileWriteOptions,
} from "@getpaseo/plugin/client";
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
import { decodeUtf8 } from "@/utils/decode-utf8";
export interface FileTarget {
cwd: string;
path: string;
}
export type FileKind = "text" | "image" | "binary";
/** A version the session has classified, so a panel can be chosen before the file is read. */
export interface FileStat {
status: "ready";
size: number;
modifiedAt: string;
revision?: string;
kind: FileKind;
mimeType: string;
}
export type FileObservation =
| FileStat
| Extract<FileVersion, { status: "missing" }>
| Extract<FileVersion, { status: "error" }>;
export interface FileContents {
bytes: Uint8Array;
size: number;
modifiedAt: string;
revision?: string;
}
/** What a handle needs from the host. The daemon implementation lives in `./daemon-session`. */
export interface FileSession {
observe(
target: FileTarget,
onObservation: (observation: FileObservation) => void,
): Promise<{ initial: FileObservation; unsubscribe(): void }>;
read(target: FileTarget): Promise<FileContents>;
write(input: {
target: FileTarget;
content: string | Uint8Array;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult>;
createUrl(input: { target: FileTarget; bytes: Uint8Array; mimeType: string }): Promise<{
url: string;
release(): void;
}>;
}
export type FileHandleSnapshot =
| { status: "loading" }
| FileStat
| { status: "missing" }
| { status: "error"; error: string };
interface Version {
size: number;
modifiedAt: string;
revision?: string;
}
/**
* One file tab target's file: its stat for choosing a panel, and the `PluginFile` every panel
* reads and writes through.
*
* The handle remembers the version it last read or wrote, and writes only over that version.
* Changes on disk reach `watch` listeners once per version; versions this handle read or wrote
* itself do not. Reads wait for this handle's writes, so a panel that mounts while another flushes
* reads the flushed file. Writes run one at a time, and wait while the tab holds them.
*
* Once the file has a stat, the snapshot keeps it: the mounted panel keeps what it shows, and
* learns about a deletion from `watch`. A deletion is reported only after a read confirms it, so a
* program that removes the file and writes it back does not look like one.
*/
export class FileHandle {
readonly file: PluginFile;
private readonly listeners = new Set<() => void>();
private readonly watchers = new Set<(event: PluginFileEvent) => void>();
private snapshot: FileHandleSnapshot = { status: "loading" };
private stat: FileStat | null = null;
private latest: FileObservation | null = null;
private base: Version | null = null;
/** Versions this handle has read, written, or told watchers about. */
private readonly seen = new Set<string>();
private deleted = false;
private confirmingDeletion = false;
private hasBom = false;
private writes: Promise<unknown> = Promise.resolve();
private writing = 0;
private holds = 0;
private releaseHold: (() => void) | null = null;
private held: Promise<void> | null = null;
private observing: { generation: number; unsubscribe: (() => void) | null } | null = null;
private generation = 0;
private currentUrl: { key: string; url: Promise<string>; release: () => void } | null = null;
private disposed = false;
constructor(
private readonly session: FileSession,
private readonly target: FileTarget,
absolutePath: string,
) {
const stat = () => this.stat;
const name = absolutePath.split(/[\\/]/).pop() ?? "";
const dot = name.lastIndexOf(".");
this.file = {
path: absolutePath,
name,
extension: dot < 0 ? "" : name.slice(dot + 1).toLowerCase(),
get size() {
return stat()?.size ?? 0;
},
get modifiedAt() {
return stat()?.modifiedAt ?? "";
},
get mimeType() {
return stat()?.mimeType ?? "application/octet-stream";
},
readText: () => this.readText(),
readBytes: () => this.readBytes(),
url: () => this.url(),
writeText: (text, options) => this.writeText(text, options),
writeBytes: (bytes, options) => this.write(bytes, options),
watch: (listener) => {
this.watchers.add(listener);
return () => this.watchers.delete(listener);
},
};
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FileHandleSnapshot => this.snapshot;
/** Observes the file while the tab is on screen. Coming back reports what changed meanwhile. */
setObserving(observing: boolean): void {
if (this.disposed || observing === (this.observing !== null)) return;
if (!observing) {
this.stopObserving();
return;
}
const generation = ++this.generation;
this.observing = { generation, unsubscribe: null };
void this.observe(generation);
}
private async observe(generation: number): Promise<void> {
let subscription: Awaited<ReturnType<FileSession["observe"]>>;
try {
subscription = await this.session.observe(this.target, (observation) => {
if (this.observing?.generation === generation) this.receive(observation);
});
} catch (error) {
if (this.observing?.generation !== generation) return;
this.observing = null;
this.setSnapshot({ status: "error", error: errorMessage(error) });
return;
}
if (this.observing?.generation !== generation) {
subscription.unsubscribe();
return;
}
this.observing.unsubscribe = subscription.unsubscribe;
this.receive(subscription.initial);
}
/** Observes again after a failure. */
retry = (): void => {
this.stopObserving();
this.setObserving(true);
};
/**
* Holds this file's writes while the tab asks whether to discard unsaved changes. The returned
* function lets them run; closing the tab disposes the handle, which drops them.
*/
holdWrites = (): (() => void) => {
this.holds += 1;
if (this.holds === 1) {
this.held = new Promise((resolve) => {
this.releaseHold = resolve;
});
}
let resumed = false;
return () => {
if (resumed) return;
resumed = true;
this.holds -= 1;
if (this.holds > 0) return;
this.releaseHold?.();
this.releaseHold = null;
this.held = null;
};
};
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.stopObserving();
this.currentUrl?.release();
this.currentUrl = null;
this.releaseHold?.();
this.listeners.clear();
this.watchers.clear();
}
private stopObserving(): void {
this.observing?.unsubscribe?.();
this.observing = null;
}
private receive(observation: FileObservation): void {
if (this.disposed) return;
this.latest = observation;
if (observation.status === "ready") {
this.stat = observation;
if (!this.base) this.adopt(observation);
this.setSnapshot(observation);
} else if (!this.stat) {
this.setSnapshot(
observation.status === "missing"
? { status: "missing" }
: { status: "error", error: observation.error },
);
}
this.notifyChange(observation);
}
/** Tells watchers about a disk version this handle did not read or write itself. */
private notifyChange(observation: FileObservation): void {
if (this.writing > 0 || observation.status === "error") return;
if (observation.status === "missing") {
if (!this.deleted) void this.confirmDeletion();
return;
}
this.notifyVersion(observation);
}
private notifyVersion(version: Version): void {
const key = versionKey(version);
const restored = this.deleted;
this.deleted = false;
if (this.seen.has(key) && !restored) return;
this.seen.add(key);
this.emit("changed");
}
private async confirmDeletion(): Promise<void> {
if (this.confirmingDeletion) return;
this.confirmingDeletion = true;
let contents: FileContents | null = null;
try {
contents = await this.session.read(this.target);
} catch {
// The read failing is the confirmation.
} finally {
this.confirmingDeletion = false;
}
if (this.disposed || this.writing > 0 || this.deleted) return;
if (contents) {
this.notifyVersion(contents);
return;
}
if (this.latest?.status !== "missing") return;
this.deleted = true;
this.emit("deleted");
}
private emit(event: PluginFileEvent): void {
for (const watcher of this.watchers) watcher(event);
}
private async readContents(): Promise<FileContents> {
this.assertOpen();
await this.writes;
const contents = await this.session.read(this.target);
this.assertOpen();
this.adopt(contents);
return contents;
}
private async readText(): Promise<string> {
const { bytes } = await this.readContents();
this.hasBom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
return decodeUtf8(bytes);
}
private async readBytes(): Promise<Uint8Array> {
return (await this.readContents()).bytes;
}
private url(): Promise<string> {
const current = this.currentUrl;
if (current && this.stat && current.key === versionKey(this.stat)) return current.url;
return this.createUrl();
}
private async createUrl(): Promise<string> {
const contents = await this.readContents();
const key = versionKey(contents);
if (this.currentUrl?.key === key) return this.currentUrl.url;
const created = await this.session.createUrl({
target: this.target,
bytes: contents.bytes,
mimeType: this.file.mimeType,
});
if (this.disposed) {
created.release();
throw new Error("The file tab was closed.");
}
this.currentUrl?.release();
this.currentUrl = { key, url: Promise.resolve(created.url), release: created.release };
return created.url;
}
private writeText(text: string, options?: PluginFileWriteOptions): Promise<void> {
return this.write(this.hasBom ? `${text}` : text, options);
}
private write(content: string | Uint8Array, options?: PluginFileWriteOptions): Promise<void> {
const write = this.writes.then(() => this.performWrite(content, options?.force === true));
this.writes = write.catch(() => undefined);
return write;
}
private async performWrite(content: string | Uint8Array, force: boolean): Promise<void> {
this.assertOpen();
if (this.held) await this.held;
this.assertOpen();
this.writing += 1;
try {
let expected = force ? (this.stat ?? this.base) : this.base;
for (let attempt = 0; ; attempt += 1) {
if (!expected) throw new PluginFileConflictError();
const result = await this.session.write({
target: this.target,
content,
expectedModifiedAt: expected.modifiedAt,
expectedRevision: expected.revision,
});
if (result.status === "written") {
this.adopt(result);
return;
}
if (result.status === "error") throw new Error(result.error);
const disk = result.version;
if (force && attempt === 0 && disk.status === "ready") {
expected = disk;
continue;
}
throw new PluginFileConflictError(
disk.status === "missing" ? "The file was deleted." : undefined,
);
}
} finally {
this.writing -= 1;
// Observations that arrived during the write may be its own version, so they wait for it.
if (this.latest && !this.disposed) this.notifyChange(this.latest);
}
}
/** Records a version this handle read or wrote, so its observation does not notify. */
private adopt(version: Version): void {
this.base = { size: version.size, modifiedAt: version.modifiedAt, revision: version.revision };
this.seen.add(versionKey(version));
if (this.stat && versionKey(this.stat) !== versionKey(version)) {
this.stat = { ...this.stat, ...this.base };
this.setSnapshot(this.stat);
}
}
private assertOpen(): void {
if (this.disposed) throw new Error("The file tab was closed.");
}
private setSnapshot(snapshot: FileHandleSnapshot): void {
if (sameSnapshot(this.snapshot, snapshot)) return;
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
}
function versionKey(version: Version): string {
return version.revision ?? `${version.size}:${version.modifiedAt}`;
}
function sameSnapshot(current: FileHandleSnapshot, next: FileHandleSnapshot): boolean {
if (current.status !== next.status) return false;
if (current.status === "ready" && next.status === "ready") {
return (
versionKey(current) === versionKey(next) &&
current.kind === next.kind &&
current.mimeType === next.mimeType
);
}
if (current.status === "error" && next.status === "error") return current.error === next.error;
return true;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
-56
View File
@@ -1,56 +0,0 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import { useSessionStore } from "@/stores/session-store";
import { createDaemonFileSession } from "./daemon-session";
import { FileHandle, type FileHandleSnapshot, type FileTarget } from "./handle";
/** The tab target's file handle, observing while `observing`. Disposed when the target changes. */
export function useFileHandle(input: {
serverId: string;
target: FileTarget | null;
absolutePath: string;
observing: boolean;
}): FileHandle | null {
const { serverId, target, absolutePath, observing } = input;
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 workspaceFileEditing = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
);
// COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20.
const fileByteWrites = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.fileByteWrites === true,
);
const cwd = target?.cwd ?? null;
const path = target?.path ?? null;
const [handle, setHandle] = useState<{ key: string; handle: FileHandle } | null>(null);
const key = client && cwd && path ? `${cwd}:${path}` : null;
useEffect(() => {
if (!client || !cwd || !path) {
setHandle(null);
return;
}
const session = createDaemonFileSession(client, { workspaceFileEditing, fileByteWrites });
const next = new FileHandle(session, { cwd, path }, absolutePath);
setHandle({ key: `${cwd}:${path}`, handle: next });
return () => next.dispose();
}, [absolutePath, client, cwd, fileByteWrites, path, workspaceFileEditing]);
const current = handle?.key === key ? handle.handle : null;
useEffect(() => {
current?.setObserving(observing);
}, [current, observing]);
return current;
}
const LOADING: FileHandleSnapshot = { status: "loading" };
const subscribeNothing = () => () => {};
const getLoading = () => LOADING;
export function useFileHandleSnapshot(handle: FileHandle | null): FileHandleSnapshot {
return useSyncExternalStore(
handle?.subscribe ?? subscribeNothing,
handle?.getSnapshot ?? getLoading,
handle?.getSnapshot ?? getLoading,
);
}
@@ -1,57 +0,0 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from "@testing-library/react";
import { useFileText, useFileUrl } from "@getpaseo/plugin/client";
import { describe, expect, it } from "vitest";
import { FileHandle } from "./handle";
import { InMemoryFileSession } from "./testing";
function openFile(content = "first\n") {
const session = new InMemoryFileSession();
session.put("notes.md", content);
const handle = new FileHandle(session, { cwd: "/repo", path: "notes.md" }, "/repo/notes.md");
handle.setObserving(true);
return { session, handle, file: handle.file };
}
describe("file hooks", () => {
it("reads the text, then follows changes without going back to loading", async () => {
const { session, file } = openFile();
const states: string[] = [];
const { result } = renderHook(() => {
const text = useFileText(file);
states.push(text.status);
return text;
});
await waitFor(() => expect(result.current).toEqual({ status: "ready", text: "first\n" }));
act(() => session.put("notes.md", "second\n"));
await waitFor(() => expect(result.current).toEqual({ status: "ready", text: "second\n" }));
expect(states.lastIndexOf("loading")).toBeLessThan(states.indexOf("ready"));
});
it("reports a deleted file as an error", async () => {
const { session, file } = openFile();
const { result } = renderHook(() => useFileText(file));
await waitFor(() => expect(result.current.status).toBe("ready"));
act(() => session.remove("notes.md"));
await waitFor(() => expect(result.current.status).toBe("error"));
expect(result.current.status === "error" && result.current.error.message).toBe(
"The file was deleted.",
);
});
it("resolves a URL per version and releases the old one", async () => {
const { session, file } = openFile();
const { result } = renderHook(() => useFileUrl(file));
await waitFor(() => expect(result.current).toEqual({ status: "ready", url: "blob:test/1" }));
act(() => session.put("notes.md", "second\n"));
await waitFor(() => expect(result.current).toEqual({ status: "ready", url: "blob:test/2" }));
expect(session.releasedUrls).toEqual(["blob:test/1"]);
});
});
@@ -1,2 +0,0 @@
export { FileHandle, type FileHandleSnapshot, type FileStat, type FileTarget } from "./handle";
export { useFileHandle, useFileHandleSnapshot } from "./hook";
@@ -1,127 +0,0 @@
import type { FileWriteResult } from "@getpaseo/protocol/messages";
import type { FileContents, FileObservation, FileSession, FileTarget } from "./handle";
interface StoredFile {
bytes: Uint8Array;
revision: number;
}
/**
* A file session over an in-memory disk, for tests. A write delivers its own observation before
* it resolves, the order the daemon's watcher can produce.
*/
export class InMemoryFileSession implements FileSession {
readonly createdUrls: string[] = [];
readonly releasedUrls: string[] = [];
readonly writes: string[] = [];
private readonly files = new Map<string, StoredFile>();
private readonly observers = new Set<{
path: string;
onObservation: (observation: FileObservation) => void;
}>();
private revision = 0;
private urlCount = 0;
private nextWriteError: string | null = null;
/** The next write reports this error instead of writing. */
failNextWrite(error: string): void {
this.nextWriteError = error;
}
/** Writes the file as another program would. */
put(path: string, content: string | Uint8Array): void {
const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
this.files.set(path, { bytes, revision: ++this.revision });
this.notify(path);
}
remove(path: string): void {
this.files.delete(path);
this.notify(path);
}
text(path: string): string | null {
const file = this.files.get(path);
return file ? new TextDecoder("utf-8", { ignoreBOM: true }).decode(file.bytes) : null;
}
observerCount(): number {
return this.observers.size;
}
async observe(target: FileTarget, onObservation: (observation: FileObservation) => void) {
const observer = { path: target.path, onObservation };
this.observers.add(observer);
return {
initial: this.observation(target.path),
unsubscribe: () => {
this.observers.delete(observer);
},
};
}
async read(target: FileTarget): Promise<FileContents> {
const file = this.files.get(target.path);
if (!file) throw new Error("File not found");
return { bytes: file.bytes, ...version(file) };
}
async write(input: {
target: FileTarget;
content: string | Uint8Array;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult> {
if (this.nextWriteError) {
const error = this.nextWriteError;
this.nextWriteError = null;
return { status: "error", error };
}
const file = this.files.get(input.target.path);
if (!file) {
return {
status: "conflict",
version: { status: "missing", cwd: "/", path: input.target.path },
};
}
if (input.expectedRevision !== String(file.revision)) {
return {
status: "conflict",
version: { status: "ready", cwd: "/", path: input.target.path, ...version(file) },
};
}
this.writes.push(
typeof input.content === "string" ? input.content : `bytes:${input.content.length}`,
);
this.put(input.target.path, input.content);
const written = this.files.get(input.target.path)!;
return { status: "written", ...version(written) };
}
async createUrl() {
const url = `blob:test/${++this.urlCount}`;
this.createdUrls.push(url);
return { url, release: () => this.releasedUrls.push(url) };
}
private observation(path: string): FileObservation {
const file = this.files.get(path);
if (!file) return { status: "missing", cwd: "/", path };
return { status: "ready", kind: "text", mimeType: "text/plain", ...version(file) };
}
private notify(path: string): void {
const observation = this.observation(path);
for (const observer of this.observers) {
if (observer.path === path) observer.onObservation(observation);
}
}
}
function version(file: StoredFile) {
return {
size: file.bytes.length,
modifiedAt: new Date(file.revision * 1000).toISOString(),
revision: String(file.revision),
};
}
@@ -29,7 +29,7 @@ export function FileHtmlPreview({ html, testID }: { html: string; testID?: strin
return (
<iframe
data-testid={testID}
title={t("panels.file.filePanels.builtin.html")}
title={t("panels.file.editor.preview")}
srcDoc={document}
sandbox={SANDBOX}
referrerPolicy="no-referrer"
+23 -18
View File
@@ -1,18 +1,23 @@
export { FilePanelView } from "./view";
export type { FilePanel } from "./panels";
export {
useFilePanelChoice,
useFilePanelChoiceSnapshot,
type FilePanelChoice,
} from "./choice/hook";
export { FilePanelPicker } from "./choice/picker";
export { FilePanelSwitcher } from "./choice/switcher";
export {
FilePanelBarMenu,
useFilePanelOpenWithMenu,
type FilePanelOpenWithMenu,
} from "./choice/open-with";
export { DocumentFrame } from "./document-frame";
export { filePanelIcon, filePanelOptionLabel, filePanelSelectorLabel } from "./choice/presentation";
export { useFilePanelSettings, type FilePanelSettings } from "./settings/hook";
export type { FilePanelSettingsRow } from "./settings/rows";
import type { ExplorerFile } from "@/stores/session-store";
import { builtinFilePanels } from "./catalog";
import { resolveFilePanel } from "./resolve";
export { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
export { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
export { FilePanelPreview } from "./builtin";
export type FilePanel = (typeof builtinFilePanels)[number];
export function resolveFilePreviewPanel(input: {
preview: ExplorerFile | null;
path: string;
associations: Readonly<Record<string, string>>;
}) {
const { preview, path, associations } = input;
if (!preview) return null;
return resolveFilePanel({
file: { path, kind: preview.kind, size: preview.size, hasBytes: preview.kind === "image" },
candidates: builtinFilePanels,
associations,
}).panel;
}
-35
View File
@@ -1,35 +0,0 @@
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 type { Theme } from "@/styles/theme";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** The file tab's loading state. It covers the whole panel, so a panel can render beneath it. */
export function FilePanelLoading() {
const { t } = useTranslation();
return (
<View style={styles.overlay} testID="file-preview-loading">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.text}>{t("panels.file.loading")}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
...StyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
padding: theme.spacing[4],
backgroundColor: theme.colors.surface0,
},
text: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
-55
View File
@@ -1,55 +0,0 @@
import { useMemo } from "react";
import type {
PluginFile,
PluginFileLocation,
PluginFilePanelContribution,
PluginFileTab,
} from "@getpaseo/plugin/client";
import { useInstalledPlugins } from "@/plugins/registry";
import type { InstalledPlugin } from "@/plugins/types";
import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog";
export interface PluginFilePanel {
id: string;
builtin: false;
files: PluginFilePanelContribution["files"];
plugin: InstalledPlugin;
contribution: PluginFilePanelContribution;
}
export type FilePanel = RenderingFilePanel | PluginFilePanel | SourceFilePanel;
/** A built-in panel gets the plugin panel's file, tab and location, plus where the file lives. */
export interface BuiltinFilePanelProps {
file: PluginFile;
tab: PluginFileTab;
location: PluginFileLocation;
target: { serverId: string; workspaceRoot: string; path: string };
}
export function useFilePanels(serverId: string): readonly FilePanel[] {
const plugins = useInstalledPlugins();
return useMemo(
() => hostFilePanels(plugins.filter((plugin) => plugin.serverId === serverId)),
[plugins, serverId],
);
}
/** Every host's panels; a plugin installed on several hosts contributes its panels once per host. */
export function useAllHostsFilePanels(): readonly FilePanel[] {
const plugins = useInstalledPlugins();
return useMemo(() => hostFilePanels(plugins), [plugins]);
}
function hostFilePanels(plugins: readonly InstalledPlugin[]): readonly FilePanel[] {
return filePanelCatalog<PluginFilePanel>(
plugins.flatMap((plugin) =>
plugin.filePanels.map((contribution) => ({
id: `plugin:${plugin.id}/${contribution.id}`,
builtin: false,
files: contribution.files,
plugin,
contribution,
})),
),
);
}
@@ -1,54 +0,0 @@
import { useMemo } from "react";
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import type { PluginTheme } from "@getpaseo/plugin";
import { withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePaneContext } from "@/panels/pane-context";
import { useHosts } from "@/runtime/host-runtime";
import { createPluginClientStateSource } from "@/plugins/client-state/source";
import { usePluginHostNavigation } from "@/plugins/host-navigation";
import { PluginInstallationProvider } from "@/plugins/installation-provider";
import { SurfaceErrorBoundary } from "@/plugins/surface-error-boundary";
import { resolvePluginPlatform } from "@/plugins/platform";
import { toPluginTheme } from "@/plugins/theme";
import type { Theme } from "@/styles/theme";
import type { BuiltinFilePanelProps, PluginFilePanel } from "./panels";
type Props = Pick<BuiltinFilePanelProps, "file" | "tab" | "location"> & { panel: PluginFilePanel };
function PluginHost({ panel, file, tab, location, theme }: Props & { theme: PluginTheme }) {
const { workspaceId } = usePaneContext();
const { plugin, contribution } = panel;
const serverId = plugin.serverId;
const compact = useIsCompactFormFactor();
const hosts = useHosts();
const navigation = usePluginHostNavigation(serverId);
const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [serverId, hostLabel]);
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
const Component = contribution.Component;
return (
<SurfaceErrorBoundary installation={plugin} Surface={Component} key={panel.id}>
<PluginInstallationProvider plugin={plugin}>
<PluginClientStateProvider source={stateSource}>
<Component
theme={theme}
host={host}
layout={layout}
navigation={navigation}
workspaceId={workspaceId}
file={file}
tab={tab}
location={location}
/>
</PluginClientStateProvider>
</PluginInstallationProvider>
</SurfaceErrorBoundary>
);
}
const ThemedHost = withUnistyles(PluginHost);
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function PluginFilePanelHost(props: Props) {
return <ThemedHost {...props} uniProps={themeMapping} />;
}
+24 -55
View File
@@ -3,16 +3,12 @@ import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from ".
const markdown: FilePanelCandidate = {
id: "builtin:markdown",
builtin: true,
files: { extensions: ["md", "markdown"] },
fileKind: "text",
document: "text",
maxBytes: Infinity,
};
const alternative: FilePanelCandidate = {
id: "plugin:notes/markdown",
builtin: false,
files: { extensions: ["md", "markdown"] },
};
const file: FilePanelFile = { path: "/docs/README.MD", kind: "text" };
const alternative: FilePanelCandidate = { ...markdown, id: "plugin:notes/markdown" };
const file: FilePanelFile = { path: "/docs/README.MD", kind: "text", size: 100, hasBytes: true };
function resolve(
overrides: Partial<Parameters<typeof resolveFilePanel<FilePanelCandidate>>[0]> = {},
@@ -31,7 +27,6 @@ describe("resolveFilePanel", () => {
panel: alternative,
candidates: [markdown, alternative],
selectorKey: "ext:md",
associatedPanelId: alternative.id,
});
});
it("ignores an absent association without deleting it", () => {
@@ -44,43 +39,33 @@ describe("resolveFilePanel", () => {
expect(resolve().panel).toBe(markdown);
expect(resolve({ candidates: [alternative, markdown] }).panel).toBe(alternative);
});
it("uses a tab choice before the association and ignores a choice that is not a candidate", () => {
const associations = { "ext:md": alternative.id };
expect(resolve({ associations, choice: markdown.id })).toMatchObject({
panel: markdown,
associatedPanelId: alternative.id,
});
expect(resolve({ associations, choice: "plugin:absent/markdown" }).panel).toBe(alternative);
});
it("returns the floor when no selector matches", () => {
expect(resolve({ file: { ...file, path: "index.ts" } })).toEqual({
panel: null,
candidates: [],
selectorKey: "ext:ts",
associatedPanelId: null,
});
});
it("offers a panel claiming every file of its kind for files of that kind", () => {
const every: FilePanelCandidate = { ...markdown, id: "builtin:source", files: "all" };
expect(resolve({ file: { ...file, path: "index.ts" }, candidates: [every] })).toEqual({
panel: every,
candidates: [every],
selectorKey: "ext:ts",
associatedPanelId: null,
});
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [every] }).panel).toBeNull();
});
it("passes over a built-in panel for another kind of file, even with an association", () => {
it("filters text panels for binary files even with an association", () => {
expect(
resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel,
).toBe(alternative);
).toBeNull();
});
it("offers plugin panels for every kind of file their selector matches", () => {
for (const kind of ["text", "image", "binary"] as const) {
expect(resolve({ file: { ...file, kind }, candidates: [alternative] }).panel).toBe(
alternative,
);
}
it("requires bytes for bytes panels and accepts binary files with bytes", () => {
const bytes: FilePanelCandidate = { ...alternative, document: "bytes" };
expect(resolve({ file: { ...file, hasBytes: false }, candidates: [bytes] }).panel).toBeNull();
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [bytes] }).panel).toBe(bytes);
});
it("includes the maxBytes boundary and skips oversized associated panels", () => {
const limited = { ...alternative, maxBytes: 100 };
expect(resolve({ candidates: [limited] }).panel).toBe(limited);
expect(
resolve({
file: { ...file, size: 101 },
candidates: [limited, markdown],
associations: { "ext:md": limited.id },
}).panel,
).toBe(markdown);
});
it("uses exact basename keys when an eligible candidate matches by filename", () => {
const named = { ...alternative, files: { extensions: [], filenames: ["README.MD"] } };
@@ -90,19 +75,16 @@ describe("resolveFilePanel", () => {
panel: named,
candidates: [markdown, named],
selectorKey: "name:README.MD",
associatedPanelId: named.id,
});
expect(resolve({ file: { ...file, path: "README.md" }, candidates: [named] }).panel).toBeNull();
});
it("uses extension keys when filename candidates were filtered out", () => {
const named = {
...markdown,
id: "builtin:named",
...alternative,
maxBytes: 1,
files: { extensions: [], filenames: ["README.MD"] },
};
expect(
resolve({ file: { ...file, kind: "binary" }, candidates: [named, alternative] }).selectorKey,
).toBe("ext:md");
expect(resolve({ candidates: [named, markdown] }).selectorKey).toBe("ext:md");
});
it.each(["/src/Dockerfile", "C:\\src\\Dockerfile"])(
"matches an extensionless exact filename: %s",
@@ -112,7 +94,6 @@ describe("resolveFilePanel", () => {
panel: named,
candidates: [named],
selectorKey: "name:Dockerfile",
associatedPanelId: null,
});
},
);
@@ -122,15 +103,3 @@ describe("resolveFilePanel", () => {
expect(resolve({ file: { ...file, path: "README" } }).selectorKey).toBe("ext:");
});
});
it("opens a binary PDF with its plugin by its selector alone", () => {
const pdf: FilePanelCandidate = {
id: "plugin:pdf/viewer",
builtin: false,
files: { extensions: ["pdf"] },
};
const pdfFile: FilePanelFile = { path: "/workspace/document.pdf", kind: "binary" };
expect(
resolveFilePanel({ file: pdfFile, candidates: [markdown, pdf], associations: {} }).panel,
).toBe(pdf);
});
+23 -89
View File
@@ -1,9 +1,8 @@
export type FilePanelFileKind = "text" | "image" | "binary";
/** What resolution knows about a file: its path and the kind its stat reports, never contents. */
export interface FilePanelFile {
path: string;
kind: FilePanelFileKind;
kind: "text" | "image" | "binary";
size: number;
hasBytes: boolean;
}
export interface FilePanelSelector {
@@ -11,24 +10,17 @@ export interface FilePanelSelector {
filenames?: readonly string[];
}
/** A selector, or `"all"`: every file of the panel's `fileKind`. */
export type FilePanelFiles = FilePanelSelector | "all";
export interface FilePanelCandidate {
id: string;
/** Paseo's own panel. Paseo asks which panel opens a file only when a plugin panel claims it. */
builtin: boolean;
files: FilePanelFiles;
/** The only kind of file a built-in panel reads. Plugin panels are offered for any kind. */
fileKind?: FilePanelFileKind;
files: FilePanelSelector;
document: "text" | "bytes";
maxBytes: number;
}
export interface FilePanelResolution<Candidate> {
panel: Candidate | null;
candidates: readonly Candidate[];
selectorKey: string;
/** The remembered candidate for `selectorKey`, when this host has it. */
associatedPanelId: string | null;
}
export function filePanelExtension(path: string): string {
@@ -41,89 +33,31 @@ function filePanelBasename(path: string): string {
return path.trim().split(/[\\/]/).pop() ?? "";
}
/** Which files an association applies to: one extension, or one exact filename. */
export interface FilePanelSelectorKey {
kind: "ext" | "name";
value: string;
}
function filePanelSelectorKey(key: FilePanelSelectorKey): string {
return `${key.kind}:${key.value}`;
}
export function parseFilePanelSelectorKey(selectorKey: string): FilePanelSelectorKey {
const separator = selectorKey.indexOf(":");
const kind = selectorKey.slice(0, separator) === "name" ? "name" : "ext";
return { kind, value: selectorKey.slice(separator + 1) };
}
function matchesFiles(file: FilePanelFile, files: FilePanelFiles): boolean {
if (files === "all") return true;
return matchesName(filePanelBasename(file.path), filePanelExtension(file.path), files);
}
function claimsFilename(files: FilePanelFiles, filename: string): boolean {
return files !== "all" && Boolean(files.filenames?.includes(filename));
}
function matchesName(name: string, extension: string, selector: FilePanelSelector): boolean {
if (selector.filenames?.includes(name)) return true;
function matchesSelector(file: FilePanelFile, selector: FilePanelSelector): boolean {
if (selector.filenames?.includes(filePanelBasename(file.path))) return true;
const extension = filePanelExtension(file.path);
return selector.extensions.some((entry) => entry.toLowerCase() === extension);
}
/** The selector keys a panel claims by name: one per extension and one per filename. */
export function filePanelClaimedSelectorKeys(files: FilePanelFiles): string[] {
if (files === "all") return [];
const selector = files;
return [
...selector.extensions.map((extension) =>
filePanelSelectorKey({ kind: "ext", value: extension.toLowerCase() }),
),
...(selector.filenames ?? []).map((filename) =>
filePanelSelectorKey({ kind: "name", value: filename }),
),
];
}
/**
* Whether a panel is offered for files under `selectorKey`, by the resolver's matching rule. The
* file kind depends on the file, so it is not part of the answer.
*/
export function claimsFilePanelSelectorKey(
selector: FilePanelSelector,
selectorKey: string,
): boolean {
const { kind, value } = parseFilePanelSelectorKey(selectorKey);
return kind === "name"
? matchesName(value, filePanelExtension(value), selector)
: matchesName("", value, selector);
}
/** Paseo asks which panel opens a file only when a plugin panel is one of several candidates. */
export function asksForFilePanel(candidates: readonly FilePanelCandidate[]): boolean {
return candidates.length > 1 && candidates.some((candidate) => !candidate.builtin);
}
export function resolveFilePanel<Candidate extends FilePanelCandidate>(input: {
file: FilePanelFile;
candidates: readonly Candidate[];
associations: Readonly<Record<string, string>>;
/** A panel chosen for this tab open; wins over the association while it is a candidate. */
choice?: string | null;
}): FilePanelResolution<Candidate> {
const { file, associations } = input;
const candidates = input.candidates.filter(
(candidate) =>
(candidate.fileKind === undefined || candidate.fileKind === file.kind) &&
matchesFiles(file, candidate.files),
);
const candidates = input.candidates.filter((candidate) => {
const documentAvailable = candidate.document === "text" ? file.kind === "text" : file.hasBytes;
return (
documentAvailable && file.size <= candidate.maxBytes && matchesSelector(file, candidate.files)
);
});
const filename = filePanelBasename(file.path);
const matchedFilename = candidates.some((candidate) => claimsFilename(candidate.files, filename));
const selectorKey = matchedFilename
? filePanelSelectorKey({ kind: "name", value: filename })
: filePanelSelectorKey({ kind: "ext", value: filePanelExtension(file.path) });
const associated = candidates.find((candidate) => candidate.id === associations[selectorKey]);
const chosen = candidates.find((candidate) => candidate.id === input.choice);
const panel = chosen ?? associated ?? candidates[0] ?? null;
return { panel, candidates, selectorKey, associatedPanelId: associated?.id ?? null };
const matchedFilename = candidates.some((candidate) =>
candidate.files.filenames?.includes(filename),
);
const selectorKey = matchedFilename ? `name:${filename}` : `ext:${filePanelExtension(file.path)}`;
const association = associations[selectorKey];
const panel =
candidates.find((candidate) => candidate.id === association) ?? candidates[0] ?? null;
return { panel, candidates, selectorKey };
}
@@ -1,24 +0,0 @@
import { useCallback, useMemo } from "react";
import { useFilePanelAssociations } from "../associations";
import { useAllHostsFilePanels, type FilePanel } from "../panels";
import { buildFilePanelSettingsRows, type FilePanelSettingsRow } from "./rows";
export interface FilePanelSettings {
rows: readonly FilePanelSettingsRow<FilePanel>[];
/** Remembers `panelId` for the key; the next open of a matching file uses it. */
choose(selectorKey: string, panelId: string): void;
/** Forgets the key's panel, so the next open of a matching file asks. */
askAgain(selectorKey: string): void;
}
/** Settings > Files: the keys worth configuring across every host, and the commands on them. */
export function useFilePanelSettings(): FilePanelSettings {
const candidates = useAllHostsFilePanels();
const { associations, remember } = useFilePanelAssociations();
const rows = useMemo(
() => buildFilePanelSettingsRows({ candidates, associations }),
[associations, candidates],
);
const askAgain = useCallback((selectorKey: string) => remember(selectorKey, null), [remember]);
return { rows, choose: remember, askAgain };
}
@@ -1,122 +0,0 @@
import { describe, expect, it } from "vitest";
import { filePanelCatalog, sourceFilePanel } from "../catalog";
import type { FilePanelCandidate } from "../resolve";
import { buildFilePanelSettingsRows } from "./rows";
type HostedCandidate = FilePanelCandidate & { serverId?: string };
const builtinMarkdown: HostedCandidate = filePanelCatalog([])[0];
const builtinImage: HostedCandidate = filePanelCatalog([])[2];
const source: HostedCandidate = sourceFilePanel;
/** What Settings reads: the built-ins and every host's plugin panels, in catalog order. */
function catalog(...plugins: HostedCandidate[]): HostedCandidate[] {
return [...filePanelCatalog(plugins)];
}
function richMarkdown(serverId: string): HostedCandidate {
return {
id: "plugin:markdown-panel/rich-markdown",
builtin: false,
files: { extensions: ["MD"] },
serverId,
};
}
const dockerfile: HostedCandidate = {
id: "plugin:docker/dockerfile",
builtin: false,
files: { extensions: [], filenames: ["Dockerfile"] },
serverId: "host-a",
};
function rows(candidates: HostedCandidate[], associations: Record<string, string> = {}) {
return buildFilePanelSettingsRows({ candidates, associations });
}
describe("buildFilePanelSettingsRows", () => {
it("has nothing to configure when only built-in panels claim a key", () => {
expect(rows(catalog())).toEqual([]);
});
it("lists a key a plugin panel claims, built-in first and Source last", () => {
const plugin = richMarkdown("host-a");
expect(rows(catalog(plugin))).toEqual([
{
selectorKey: "ext:md",
candidates: [builtinMarkdown, plugin, source],
associatedPanelId: null,
},
]);
});
it("offers Source on a key only a plugin panel claims, as the picker does for a text file", () => {
const toml: HostedCandidate = {
id: "plugin:toml/toml",
builtin: false,
files: { extensions: ["toml"] },
};
expect(rows(catalog(toml))).toEqual([
{ selectorKey: "ext:toml", candidates: [toml, source], associatedPanelId: null },
]);
const other: HostedCandidate = { ...dockerfile, id: "plugin:other/dockerfile" };
expect(rows(catalog(dockerfile, other))[0]?.candidates).toEqual([dockerfile, other, source]);
});
it("does not offer Source on a key the built-in image panel claims", () => {
const viewer: HostedCandidate = {
id: "plugin:viewer/png",
builtin: false,
files: { extensions: ["png"] },
};
expect(rows(catalog(viewer))).toEqual([
{ selectorKey: "ext:png", candidates: [builtinImage, viewer], associatedPanelId: null },
]);
});
it("offers a panel installed on several hosts once, as the first host has it", () => {
const first = richMarkdown("host-a");
const [row] = rows(catalog(first, richMarkdown("host-b")));
expect(row?.candidates).toEqual([builtinMarkdown, first, source]);
});
it("keeps a remembered key that only built-in panels claim", () => {
expect(rows(catalog(), { "ext:markdown": source.id })).toEqual([
{
selectorKey: "ext:markdown",
candidates: [builtinMarkdown, source],
associatedPanelId: source.id,
},
]);
});
it("keeps a remembered key whose panel is on no connected host", () => {
expect(rows(catalog(), { "ext:pdf": "plugin:pdf-panel/pdf" })).toEqual([
{ selectorKey: "ext:pdf", candidates: [source], associatedPanelId: "plugin:pdf-panel/pdf" },
]);
});
it("offers extension panels on a filename key the way the resolver does", () => {
const named: HostedCandidate = {
...dockerfile,
id: "plugin:readme/readme",
files: { extensions: [], filenames: ["README.md"] },
};
const keys = rows(catalog(named)).map((row) => row.selectorKey);
expect(keys).toEqual(["name:README.md"]);
expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named, source]);
});
it("orders extensions before filenames, each alphabetically", () => {
const other: HostedCandidate = { ...dockerfile, id: "plugin:other/dockerfile" };
const html: HostedCandidate = {
...richMarkdown("host-a"),
id: "plugin:web/html",
files: { extensions: ["html"] },
};
const keys = rows(catalog(dockerfile, other, html, richMarkdown("host-a"))).map(
(row) => row.selectorKey,
);
expect(keys).toEqual(["ext:html", "ext:md", "name:Dockerfile"]);
});
});
@@ -1,79 +0,0 @@
import {
asksForFilePanel,
claimsFilePanelSelectorKey,
filePanelClaimedSelectorKeys,
parseFilePanelSelectorKey,
type FilePanelCandidate,
} from "../resolve";
/** One selector key in Settings > Files. */
export interface FilePanelSettingsRow<Candidate> {
selectorKey: string;
/** Every known panel for the key, coalesced across hosts, in resolver order. */
candidates: readonly Candidate[];
/** The remembered panel, which may be on no connected host; null means Paseo asks on open. */
associatedPanelId: string | null;
}
/**
* The keys worth configuring: every key where Paseo would ask, and every key that has an
* association, so a remembered choice can always be changed or forgotten. A panel installed on
* several hosts is one candidate.
*/
export function buildFilePanelSettingsRows<Candidate extends FilePanelCandidate>(input: {
candidates: readonly Candidate[];
associations: Readonly<Record<string, string>>;
}): FilePanelSettingsRow<Candidate>[] {
const candidates = coalesceById(input.candidates);
const keys = new Set([
...candidates.flatMap((candidate) => filePanelClaimedSelectorKeys(candidate.files)),
...Object.keys(input.associations),
]);
return [...keys]
.sort(compareSelectorKeys)
.map((selectorKey) => ({
selectorKey,
candidates: offeredCandidates(candidates, selectorKey),
associatedPanelId: input.associations[selectorKey] ?? null,
}))
.filter((row) => asksForFilePanel(row.candidates) || row.associatedPanelId !== null);
}
/**
* The panels offered under a key, by the resolver's matching rule. A panel claiming every file of
* a kind (Source) is offered unless a built-in panel claiming the key reads another kind: without a
* file, that is the only knowledge of the key's kind. A remembered panel that does not fit the file
* when it opens is ignored there.
*/
function offeredCandidates<Candidate extends FilePanelCandidate>(
candidates: readonly Candidate[],
selectorKey: string,
): Candidate[] {
const claiming = candidates.filter(
(candidate) =>
candidate.files !== "all" && claimsFilePanelSelectorKey(candidate.files, selectorKey),
);
return candidates.filter((candidate) =>
candidate.files === "all"
? !claiming.some((claimant) => claimant.builtin && claimant.fileKind !== candidate.fileKind)
: claiming.includes(candidate),
);
}
function coalesceById<Candidate extends { id: string }>(
candidates: readonly Candidate[],
): Candidate[] {
const byId = new Map<string, Candidate>();
for (const candidate of candidates) {
if (!byId.has(candidate.id)) byId.set(candidate.id, candidate);
}
return [...byId.values()];
}
/** Extensions before filenames, each alphabetically. */
function compareSelectorKeys(left: string, right: string): number {
const a = parseFilePanelSelectorKey(left);
const b = parseFilePanelSelectorKey(right);
if (a.kind !== b.kind) return a.kind === "ext" ? -1 : 1;
return a.value.localeCompare(b.value);
}
@@ -1,240 +0,0 @@
import { describe, expect, test } from "vitest";
import { FileHandle } from "../../file/handle";
import { InMemoryFileSession } from "../../file/testing";
import { FileEditorModel, getFileConflictCallout, type FileEditorClock } 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;
}
get scheduled(): boolean {
return this.callback !== null;
}
fire(): void {
const callback = this.callback;
this.callback = null;
callback?.();
}
}
function settle(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function openEditor(content: string | Uint8Array = "first\n") {
const session = new InMemoryFileSession();
session.put("source.ts", content);
const handle = new FileHandle(session, { cwd: "/repo", path: "source.ts" }, "/repo/source.ts");
handle.setObserving(true);
await settle();
const clock = new TestClock();
const model = new FileEditorModel({ file: handle.file, clock });
model.open();
await settle();
return { session, handle, clock, model, disk: () => session.text("source.ts") };
}
describe("FileEditorModel", () => {
test("loads the file clean", async () => {
const { model } = await openEditor();
expect(model.getSnapshot()).toMatchObject({
status: "clean",
content: "first\n",
modified: false,
});
});
test("reports a file it cannot read", async () => {
const session = new InMemoryFileSession();
const handle = new FileHandle(session, { cwd: "/repo", path: "gone.ts" }, "/repo/gone.ts");
const model = new FileEditorModel({ file: handle.file });
model.open();
await settle();
expect(model.getSnapshot()).toMatchObject({ status: "failed", error: "File not found" });
});
test("tracks whether the buffer differs from the file", async () => {
const { model } = await openEditor();
model.edit("second\n");
expect(model.getSnapshot()).toMatchObject({ status: "dirty", modified: true });
model.edit("first\n");
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
});
test("autosaves the latest edit after inactivity", async () => {
const { model, clock, disk } = await openEditor();
model.edit("draft\n");
model.edit("latest\n");
clock.fire();
await settle();
expect(disk()).toBe("latest\n");
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
});
test("keeps a newer edit modified when an older save finishes", async () => {
const { model, disk } = await openEditor();
model.edit("saved\n");
const save = model.save();
model.edit("newer\n");
await save;
expect(disk()).toBe("saved\n");
expect(model.getSnapshot()).toMatchObject({ status: "dirty", modified: true });
});
test("keeps CRLF content and the byte order mark in file form", async () => {
const { model, disk } = await openEditor(
new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode("a\r\nb\n")]),
);
expect(model.getSnapshot()).toMatchObject({ content: "a\r\nb\n", lineSeparator: "\r\n" });
model.edit("c\r\nd\r\n");
await model.save();
expect(disk()).toBe("c\r\nd\r\n");
});
test("follows the disk while clean", async () => {
const { session, model } = await openEditor();
session.put("source.ts", "external\r\n");
await settle();
expect(model.getSnapshot()).toMatchObject({
status: "clean",
content: "external\r\n",
lineSeparator: "\r\n",
});
});
test("conflicts when the disk changes under a modified buffer, and overwrites", async () => {
const { session, model, disk } = await openEditor();
model.edit("local\n");
session.put("source.ts", "external\n");
await settle();
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local\n" });
expect(getFileConflictCallout(model.getSnapshot())).toEqual({
kind: "changed",
canOverwrite: true,
});
await model.overwrite();
expect(disk()).toBe("local\n");
expect(model.getSnapshot()).toMatchObject({ status: "clean", conflict: null });
});
test("reload discards a conflicted buffer for the disk", async () => {
const { session, model } = await openEditor();
model.edit("local\n");
session.put("source.ts", "external\n");
await settle();
await model.reload();
expect(model.getSnapshot()).toMatchObject({
status: "clean",
content: "external\n",
modified: false,
});
});
test("a save over an unobserved change conflicts instead of writing", async () => {
const { session, handle, model, disk } = await openEditor();
handle.setObserving(false);
session.put("source.ts", "external\n");
model.edit("local\n");
await model.save();
expect(disk()).toBe("external\n");
expect(model.getSnapshot()).toMatchObject({ status: "conflict", conflict: "changed" });
});
test("stays clean when the disk changes back to its own content", async () => {
const { session, model, clock } = await openEditor();
model.edit("local\n");
session.put("source.ts", "first\n");
await settle();
expect(model.getSnapshot()).toMatchObject({ status: "dirty", content: "local\n" });
expect(clock.scheduled).toBe(true);
});
test("a deletion conflicts with local changes and stops autosave", async () => {
const { session, model, clock } = await openEditor();
model.edit("local\n");
session.remove("source.ts");
await settle();
expect(getFileConflictCallout(model.getSnapshot())).toEqual({ kind: "deleted" });
expect(clock.scheduled).toBe(false);
});
test("an external change during a save is reconciled after it", async () => {
const { session, handle, model } = await openEditor();
model.edit("local\n");
const resume = handle.holdWrites();
const save = model.save();
session.put("source.ts", "external\n");
resume();
await save;
await settle();
expect(model.getSnapshot()).toMatchObject({ status: "conflict", conflict: "changed" });
});
test("reports failed saves without losing the buffer", async () => {
const { session, model } = await openEditor();
session.failNextWrite("disk full");
model.edit("local\n");
await model.save();
expect(model.getSnapshot()).toMatchObject({
status: "error",
content: "local\n",
error: "disk full",
});
});
test("flushes a modified buffer when disposed for another panel", async () => {
const { model, disk } = await openEditor();
model.edit("switching\n");
model.dispose({ flush: true });
await settle();
expect(disk()).toBe("switching\n");
});
test("discards the buffer when the tab closes while holding writes", async () => {
const { handle, model, disk } = await openEditor();
model.edit("discarded\n");
handle.holdWrites();
model.dispose({ flush: true });
handle.dispose();
await settle();
expect(disk()).toBe("first\n");
});
});
@@ -1,287 +0,0 @@
import {
PluginFileConflictError,
type PluginFile,
type PluginFileEvent,
} from "@getpaseo/plugin/client";
export type FileEditorStatus =
| "loading"
| "failed"
| "clean"
| "dirty"
| "saving"
| "conflict"
| "error";
export type FileLineSeparator = "\n" | "\r\n" | "\r";
export interface FileEditorSnapshot {
status: FileEditorStatus;
content: string;
lineSeparator: FileLineSeparator;
/** The buffer differs from what this editor last read or saved. */
modified: boolean;
/** Why the editor is in conflict: the file changed or was deleted on disk. */
conflict: "changed" | "deleted" | null;
/** The failed load's or save's message. */
error: string | null;
}
export type FileConflictCallout = { kind: "changed"; canOverwrite: boolean } | { kind: "deleted" };
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);
},
};
const AUTOSAVE_DELAY_MS = 800;
/**
* Source's buffer over a `PluginFile`: reads the file, autosaves edits, and reconciles changes on
* disk. A clean buffer follows the disk; a modified one conflicts and the user overwrites or
* reloads. The file handle rejects writes over versions this editor has not read.
*/
export class FileEditorModel {
private readonly file: PluginFile;
private readonly clock: FileEditorClock;
private readonly listeners = new Set<() => void>();
private snapshot: FileEditorSnapshot = {
status: "loading",
content: "",
lineSeparator: "\n",
modified: false,
conflict: null,
error: null,
};
private persisted = "";
private autosave: ReturnType<typeof setTimeout> | null = null;
private saving = false;
private saveSequence = 0;
private readSequence = 0;
private eventDuringSave: PluginFileEvent | null = null;
private unwatch: (() => void) | null = null;
private disposed = false;
constructor(input: { file: PluginFile; clock?: FileEditorClock }) {
this.file = input.file;
this.clock = input.clock ?? systemClock;
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FileEditorSnapshot => this.snapshot;
/** Reads the file and follows its changes on disk. */
open(): void {
if (this.disposed || this.unwatch) return;
this.unwatch = this.file.watch((event) => this.receiveFileEvent(event));
void this.reload();
}
edit(content: string): void {
if (this.disposed || !this.isLoaded() || content === this.snapshot.content) return;
const modified = content !== this.persisted;
let status: FileEditorStatus = modified ? "dirty" : "clean";
if (this.snapshot.status === "conflict") 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.snapshot.status !== "dirty" && this.snapshot.status !== "error") return;
await this.write({ force: false });
}
/** Writes the buffer over a file that changed on disk. */
async overwrite(): Promise<void> {
if (this.snapshot.conflict !== "changed") return;
await this.write({ force: true });
}
/** Replaces the buffer with the file on disk. */
async reload(): Promise<void> {
if (this.disposed) return;
const sequence = ++this.readSequence;
try {
const content = await this.file.readText();
if (this.disposed || sequence !== this.readSequence) return;
this.applyFile(content);
} catch (error) {
if (this.disposed || sequence !== this.readSequence) return;
if (this.isLoaded()) return;
this.setSnapshot({ ...this.snapshot, status: "failed", error: errorMessage(error) });
}
}
/**
* Stops following the file. With `flush`, a modified buffer is written first, so switching to
* another panel keeps the edit. The tab holds that write while it asks to discard it.
*/
dispose({ flush }: { flush: boolean }): void {
if (this.disposed) return;
const status = this.snapshot.status;
if (flush && this.snapshot.modified && status !== "conflict") {
void this.file.writeText(this.snapshot.content).catch(() => undefined);
}
this.disposed = true;
this.clearAutosave();
this.unwatch?.();
this.unwatch = null;
this.listeners.clear();
}
private receiveFileEvent(event: PluginFileEvent): void {
if (this.disposed) return;
if (!this.isLoaded()) {
if (event === "changed") void this.reload();
return;
}
if (this.saving) {
this.eventDuringSave = event;
return;
}
if (event === "deleted") {
this.enterConflict("deleted");
return;
}
void this.reconcile();
}
/** Follows a clean buffer to the disk; a modified one conflicts unless the disk matches it. */
private async reconcile(): Promise<void> {
const sequence = ++this.readSequence;
let content: string;
try {
content = await this.file.readText();
} catch {
return;
}
if (this.disposed || sequence !== this.readSequence) return;
if (this.saving) {
this.eventDuringSave = "changed";
return;
}
if (content === this.persisted) {
this.adoptUnchangedFile();
return;
}
if (!this.snapshot.modified) {
this.applyFile(content);
return;
}
this.enterConflict("changed");
}
private async write({ force }: { force: boolean }): Promise<void> {
if (this.disposed) return;
this.clearAutosave();
const sequence = ++this.saveSequence;
const content = this.snapshot.content;
this.saving = true;
this.eventDuringSave = null;
this.setSnapshot({ ...this.snapshot, status: "saving", conflict: null, error: null });
try {
await this.file.writeText(content, { force });
} catch (error) {
this.saving = false;
if (this.disposed || sequence !== this.saveSequence) return;
if (error instanceof PluginFileConflictError) this.enterConflict("changed");
else this.setSnapshot({ ...this.snapshot, status: "error", error: errorMessage(error) });
this.receivePendingEvent();
return;
}
this.saving = false;
if (this.disposed || sequence !== this.saveSequence) return;
this.persisted = content;
const modified = this.snapshot.content !== this.persisted;
this.setSnapshot({ ...this.snapshot, status: modified ? "dirty" : "clean", modified });
if (modified) this.scheduleAutosave();
this.receivePendingEvent();
}
private receivePendingEvent(): void {
const event = this.eventDuringSave;
this.eventDuringSave = null;
if (event) this.receiveFileEvent(event);
}
private applyFile(content: string): void {
this.clearAutosave();
this.saveSequence += 1;
this.persisted = content;
this.setSnapshot({
status: "clean",
content,
lineSeparator: detectLineSeparator(content),
modified: false,
conflict: null,
error: null,
});
}
private adoptUnchangedFile(): void {
if (this.snapshot.status !== "conflict") return;
const status = this.snapshot.modified ? "dirty" : "clean";
this.setSnapshot({ ...this.snapshot, status, conflict: null, error: null });
if (status === "dirty") this.scheduleAutosave();
}
private enterConflict(conflict: "changed" | "deleted"): void {
this.clearAutosave();
this.setSnapshot({ ...this.snapshot, status: "conflict", conflict, error: null });
}
private isLoaded(): boolean {
return this.snapshot.status !== "loading" && this.snapshot.status !== "failed";
}
private scheduleAutosave(): void {
this.clearAutosave();
this.autosave = this.clock.setTimeout(() => {
this.autosave = null;
void this.save();
}, AUTOSAVE_DELAY_MS);
}
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();
}
}
export function getFileConflictCallout(snapshot: FileEditorSnapshot): FileConflictCallout | null {
if (snapshot.status !== "conflict" || !snapshot.conflict) return null;
if (snapshot.conflict === "deleted") return { kind: "deleted" };
return { kind: "changed", canOverwrite: snapshot.modified };
}
function detectLineSeparator(content: string): FileLineSeparator {
for (let index = 0; index < content.length; index += 1) {
const character = content.charCodeAt(index);
if (character === 10) return "\n";
if (character === 13) return content.charCodeAt(index + 1) === 10 ? "\r\n" : "\r";
}
return "\n";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -1,209 +0,0 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useFileText } from "@getpaseo/plugin/client";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { isWeb } from "@/constants/platform";
import { useAppSettings } from "@/hooks/use-settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { formatFileSize } from "@/utils/format-file-size";
import type { Theme } from "@/styles/theme";
import { FilePanelLoading } from "../loading";
import type { BuiltinFilePanelProps } from "../panels";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import { FileEditorModel, getFileConflictCallout } from "./editor/model";
import { FileEditorView } from "./editor/view";
import { selectSourcePresentation } from "./readonly/presentation";
import { FileSourceView } from "./readonly/view";
import { SourceStatusRow } from "./status";
// CodeMirror and native source rendering consume appearance as props. Subscribe at the
// rendering leaf so theme updates reconfigure it without replacing the editor/model.
const sourceAppearance = (theme: Theme) => ({
theme: {
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,
},
});
const ThemedFileEditorView = withUnistyles(FileEditorView, sourceAppearance);
const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance);
/** The daemon refuses to write larger files. */
const MAX_EDITABLE_BYTES = 1024 * 1024;
/**
* Paseo's editor for text files. Edits files up to 1 MiB on web; shows larger files and every file
* on native read-only, and refuses files above the display budget before reading them.
*/
export function SourcePanel(props: BuiltinFilePanelProps) {
const size = props.file.size;
const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" });
if (presentation === "unsupported") return <TooLargeSource size={size} />;
if (isWeb && size <= MAX_EDITABLE_BYTES) return <EditableSource {...props} />;
return <ReadonlySource {...props} presentation={presentation} />;
}
function TooLargeSource({ size }: { size: number }) {
const { t } = useTranslation();
return (
<View style={styles.centerState} testID="file-source-too-large">
<Text style={styles.mutedText}>{t("panels.file.tooLargeToDisplay")}</Text>
<Text style={styles.mutedText}>{formatFileSize(size)}</Text>
</View>
);
}
function ReadonlySource({
file,
location,
target,
presentation,
}: BuiltinFilePanelProps & { presentation: "highlighted" | "plain" }) {
const text = useFileText(file);
const { t } = useTranslation();
const sourceLocation = useMemo(
() => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }),
[location.lineEnd, location.lineStart, target.path],
);
if (text.status === "loading") return <FilePanelLoading />;
if (text.status === "error") {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>{text.error.message || t("panels.file.failedToLoad")}</Text>
</View>
);
}
return (
<View style={styles.container}>
<ThemedFileSourceView
content={text.text}
filename={target.path}
location={sourceLocation}
navigationRevision={location.revision}
presentation={presentation}
/>
<SourceStatusRow size={file.size} lineCount={text.text.split("\n").length} />
</View>
);
}
function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps) {
const { settings } = useAppSettings();
const { t } = useTranslation();
const [model] = useState(() => new FileEditorModel({ file }));
const [editorReady, setEditorReady] = useState(false);
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
useEffect(() => {
model.open();
return () => model.dispose({ flush: true });
}, [model]);
useEffect(() => {
tab.setModified(snapshot.modified);
}, [snapshot.modified, tab]);
const handleReload = useCallback(() => {
if (!snapshot.modified) {
void model.reload();
return;
}
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, snapshot.modified, t]);
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
const handleRetry = useCallback(() => void model.reload(), [model]);
const handleReady = useCallback(() => setEditorReady(true), []);
const editorLocation = useMemo(
() => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }),
[location.lineEnd, location.lineStart, target.path],
);
if (snapshot.status === "loading") return <FilePanelLoading />;
if (snapshot.status === "failed") {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>{snapshot.error ?? t("panels.file.failedToLoad")}</Text>
<Button variant="outline" size="sm" onPress={handleRetry}>
{t("common.actions.retry")}
</Button>
</View>
);
}
const callout = getFileConflictCallout(snapshot);
let conflict: FileConflictAlertState | null = null;
if (callout?.kind === "deleted") conflict = { kind: "deleted" };
else if (callout) {
conflict = {
kind: "changed",
canOverwrite: callout.canOverwrite,
onReload: handleReload,
onOverwrite: handleOverwrite,
};
}
return (
<View style={styles.container}>
{conflict ? <FileConflictAlert state={conflict} /> : null}
<View style={styles.editor}>
<ThemedFileEditorView
model={model}
filename={target.path}
location={editorLocation}
navigationRevision={location.revision}
vimEnabled={settings.vimKeybindings}
onCursorChange={setCursor}
onVimModeChange={setVimMode}
onReady={handleReady}
/>
{editorReady ? null : <FilePanelLoading />}
</View>
<SourceStatusRow
size={file.size}
lineCount={snapshot.content.split("\n").length}
editorStatus={snapshot.status}
vimMode={vimMode}
cursor={cursor}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: { flex: 1, minHeight: 0 },
editor: { flex: 1, minHeight: 0 },
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
padding: theme.spacing[4],
},
mutedText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
},
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.base,
textAlign: "center",
},
}));
@@ -1,67 +0,0 @@
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { describe, expect, it } from "vitest";
import { buildSourceLines } from "./source-lines";
const large = Array.from({ length: 14000 }, (_, i) => `export const item${i} = ${i};`).join("\n");
/** Collects every report until `count` have arrived. */
function build(
input: { presentation: "highlighted" | "plain"; content?: string; firstLines?: number },
count = 1,
) {
return new Promise<HighlightToken[][][]>((resolve) => {
const reports: HighlightToken[][][] = [];
buildSourceLines(
{
content: input.content ?? large,
filename: "large.ts",
presentation: input.presentation,
firstLines: input.firstLines ?? 0,
},
(lines) => {
reports.push(lines);
if (reports.length === count) resolve(reports);
},
);
});
}
describe("buildSourceLines", () => {
it("reports the first lines, then the whole file, leaving the thread free between slices", async () => {
let ticks = 0;
const ticker = setInterval(() => ticks++, 0);
const [first, whole] = await build({ presentation: "highlighted", firstLines: 300 }, 2);
clearInterval(ticker);
const highlighted = highlightCode(large, "large.ts");
expect(first.slice(0, 300)).toEqual(highlighted.slice(0, 300));
expect(first[300]).toEqual([{ text: "export const item300 = 300;", style: null }]);
expect(whole).toEqual(highlighted);
expect(ticks).toBeGreaterThan(1);
});
it("splits plain lines without highlighting them", async () => {
expect(await build({ presentation: "plain", content: "const a = 1;\n\nb" })).toEqual([
[
[{ text: "const a = 1;", style: null }],
[{ text: "", style: null }],
[{ text: "b", style: null }],
],
]);
});
it("never reports lines once cancelled", async () => {
let reported = false;
const cancel = buildSourceLines(
{ content: large, filename: "large.ts", presentation: "highlighted", firstLines: 0 },
() => {
reported = true;
},
);
await new Promise((resolve) => setTimeout(resolve, 20));
cancel();
await new Promise((resolve) => setTimeout(resolve, 300));
expect(reported).toBe(false);
});
});
@@ -1,64 +0,0 @@
import { highlightCodeInSteps, type HighlightToken } from "@getpaseo/highlight";
/** JavaScript time per slice; the rest of the frame stays free for input and rendering. */
const SLICE_BUDGET_MS = 12;
const PLAIN_LINES_PER_UNIT = 2000;
/**
* Splits `content` into source lines, highlighted or plain, a time slice per task so a large file
* never blocks the JavaScript thread. Highlighting reports `firstLines` lines highlighted, the
* rest plain, before the whole file. Returns a cancel function.
*/
export function buildSourceLines(
input: {
content: string;
filename: string;
presentation: "highlighted" | "plain";
firstLines: number;
},
onLines: (lines: HighlightToken[][]) => void,
): () => void {
const steps =
input.presentation === "highlighted"
? highlightCodeInSteps(input.content, input.filename, { firstLines: input.firstLines })
: plainLinesInSteps(input.content);
let cancelSlice = scheduleSlice(runSlice);
function runSlice() {
const deadline = performance.now() + SLICE_BUDGET_MS;
do {
const step = steps.next();
if (step.done) {
onLines(step.value);
return;
}
if (step.value) onLines(step.value);
} while (performance.now() < deadline);
cancelSlice = scheduleSlice(runSlice);
}
return () => cancelSlice();
}
/**
* Runs `slice` once the JavaScript thread is idle. React Native runs idle callbacks after pending
* events and renders without waiting for a frame; a zero timeout waits for the next frame.
*/
function scheduleSlice(slice: () => void): () => void {
if (typeof requestIdleCallback === "function") {
const id = requestIdleCallback(slice);
return () => cancelIdleCallback(id);
}
const timer = setTimeout(slice, 0);
return () => clearTimeout(timer);
}
function* plainLinesInSteps(content: string): Generator<undefined, HighlightToken[][], void> {
const lines: HighlightToken[][] = [];
let from = 0;
for (;;) {
const end = content.indexOf("\n", from);
lines.push([{ text: content.slice(from, end === -1 ? content.length : end), style: null }]);
if (end === -1) return lines;
from = end + 1;
if (lines.length % PLAIN_LINES_PER_UNIT === 0) yield;
}
}
@@ -1,136 +0,0 @@
import { memo, useEffect, useRef, useState } from "react";
import { FlatList, Text, View, type ListRenderItem } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { FilePanelLoading } from "../../loading";
import type { SourcePresentation } from "./presentation";
import { buildSourceLines } from "./source-lines";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
presentation: Exclude<SourcePresentation, "unsupported">;
theme: EditorVisualTheme;
}
/** Lines past the opening line highlighted before the rest of a large file. */
const FIRST_LINES = 200;
/**
* A virtualized, read-only source view. Shows Loading until the lines around the opening line are
* highlighted; the rest highlight in the background. A changed file keeps showing the previous
* lines until its own are ready.
*/
export function FileSourceView({
content,
filename,
location,
navigationRevision,
presentation,
}: FileSourceViewProps) {
const listRef = useRef<FlatList<HighlightToken[]>>(null);
const [lines, setLines] = useState<HighlightToken[][] | null>(null);
// Navigating within the file does not rebuild its lines; a new build starts at the latest line.
const openingLine = useRef(location.lineStart);
useEffect(() => {
openingLine.current = location.lineStart;
});
useEffect(() => {
const firstLines = (openingLine.current ?? 1) + FIRST_LINES;
return buildSourceLines({ content, filename, presentation, firstLines }, setLines);
}, [content, filename, presentation]);
const lineCount = lines?.length ?? 0;
useEffect(() => {
if (!location.lineStart || lineCount === 0) return;
listRef.current?.scrollToIndex({
index: Math.min(location.lineStart - 1, lineCount - 1),
animated: false,
viewPosition: 0.5,
});
}, [lineCount, location.lineStart, navigationRevision]);
if (!lines) return <FilePanelLoading />;
return (
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
renderItem={renderSourceLine}
/>
);
}
interface SourceLineProps {
tokens: HighlightToken[];
number: number;
}
// Finishing the highlight replaces every line; the lines already highlighted stay as they were.
const SourceLineView = memo(function SourceLineView({ tokens, number }: SourceLineProps) {
// A token's offset in its line identifies it; its text and style can repeat.
let offset = 0;
return (
<View style={styles.line}>
<Text style={styles.gutter}>{number}</Text>
<Text selectable style={styles.text}>
{tokens.map((token) => {
const key = offset;
offset += token.text.length;
return (
<Text key={key} style={syntaxTokenStyleFor(token.style)}>
{token.text}
</Text>
);
})}
</Text>
</View>
);
}, sameSourceLine);
function sameSourceLine(previous: SourceLineProps, next: SourceLineProps): boolean {
return (
previous.number === next.number &&
previous.tokens.length === next.tokens.length &&
previous.tokens.every(
(token, index) =>
token.text === next.tokens[index].text && token.style === next.tokens[index].style,
)
);
}
function sourceLineKey(_tokens: HighlightToken[], index: number): string {
return String(index);
}
function sourceLineLayout(_data: ArrayLike<HighlightToken[]> | null | undefined, index: number) {
return { length: 20, offset: index * 20, index };
}
const renderSourceLine: ListRenderItem<HighlightToken[]> = ({ item, index }) => (
<SourceLineView tokens={item} number={index + 1} />
);
const styles = StyleSheet.create((theme) => ({
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
gutter: {
width: 56,
paddingRight: theme.spacing[3],
textAlign: "right",
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
text: {
flex: 1,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
},
}));
@@ -1,122 +0,0 @@
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 type { Theme } from "@/styles/theme";
import { formatFileSize } from "@/utils/format-file-size";
import type { FileEditorStatus } from "./editor/model";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** Source's status row: the file's size and lines, the save state, the vim mode and the cursor. */
export function SourceStatusRow({
size,
lineCount,
editorStatus,
vimMode,
cursor,
}: {
size: number;
lineCount: number;
editorStatus?: FileEditorStatus;
vimMode?: string | null;
cursor?: { line: number; column: number };
}) {
const { t } = useTranslation();
return (
<View style={styles.row} testID="file-source-status">
<View style={styles.metadata}>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{formatFileSize(size)}
</Text>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
>
{t("panels.file.editor.lines", { count: lineCount })}
</Text>
</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}
{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>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexShrink: 0,
height: 28,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
metadata: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
status: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
dirtyDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundExtraMuted,
},
vim: {
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
},
}));
-9
View File
@@ -1,9 +0,0 @@
import type { FilePanel, BuiltinFilePanelProps } from "./panels";
import { BuiltinFilePanelView } from "./builtin";
import { PluginFilePanelHost } from "./plugin-host";
/** Renders a file panel as the file tab's body. The panel owns its loading, errors and saving. */
export function FilePanelView({ panel, ...props }: BuiltinFilePanelProps & { panel: FilePanel }) {
if (panel.builtin) return <BuiltinFilePanelView {...props} panel={panel} />;
return <PluginFilePanelHost {...props} panel={panel} />;
}
@@ -5,6 +5,7 @@ import {
type AgentFileExplorerState,
type ExplorerDirectory,
} from "@/stores/session-store";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { usePanelStore } from "@/stores/panel-store";
import { revealExplorerDirectory } from "@/file-explorer/tree";
import { parentExplorerPath } from "@/utils/explorer-paths";
@@ -12,6 +13,7 @@ import { parentExplorerPath } from "@/utils/explorer-paths";
function createExplorerState(): AgentFileExplorerState {
return {
directories: new Map(),
files: new Map(),
isLoading: false,
lastError: null,
pendingRequest: null,
@@ -146,6 +148,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
const directories = new Map(state.directories);
@@ -192,6 +195,70 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
[requestDirectoryListing, workspaceStateKey],
);
const requestFilePreview = useCallback(
async (path: string) => {
if (!workspaceStateKey) {
return;
}
const normalizedPath = path && path.length > 0 ? path : ".";
updateExplorerState((state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path: normalizedPath, mode: "file" },
}));
if (!normalizedWorkspaceRoot) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: t("workspace.fileExplorer.states.unavailable"),
pendingRequest: null,
}));
return;
}
if (!client) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: t("workspace.terminal.hostDisconnected"),
pendingRequest: null,
}));
return;
}
try {
const file = await client.readFile(normalizedWorkspaceRoot, normalizedPath);
updateExplorerState((state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
const files = new Map(state.files);
const explorerFile = explorerFileFromReadResult(file);
files.set(explorerFile.path, explorerFile);
nextState.files = files;
return nextState;
});
} catch (error) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : t("panels.file.failedToLoadPreview"),
pendingRequest: null,
}));
}
},
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
);
const requestFileDownloadToken = useCallback(
async (path: string) => {
if (!normalizedWorkspaceRoot) {
@@ -312,6 +379,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
workspaceStateKey,
revealDirectory,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
requestFileBytes,
createEntry,
+3 -28
View File
@@ -1918,31 +1918,12 @@ export const ar: TranslationResources = {
creatingAgent: "وكيل الخلق",
},
file: {
filePanels: {
pickerTitleExtension: "فتح ملفات .{{extension}} باستخدام",
pickerTitleFilename: "فتح {{filename}} باستخدام",
dontAskAgain: "لا تسألني مرة أخرى",
use: "استخدام",
openWith: "فتح باستخدام…",
alwaysUseExtension: "استخدام دائمًا لـ .{{extension}}",
alwaysUseFilename: "استخدام دائمًا لـ {{filename}}",
option: "{{title}} — {{owner}}",
actions: "إجراءات الملف",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "صورة",
source: "المصدر",
},
},
directoryMissing: "لم يتم العثور على دليل Workspace.",
loading: "جارٍ تحميل الملف...",
noPreview: "لا تتوفر معاينة",
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
failedToLoad: "فشل تحميل الملف",
notFound: "الملف غير موجود",
failedToLoadPreview: "فشل تحميل معاينة الملف",
editor: {
fileSize: "حجم الملف {{size}}",
@@ -1954,7 +1935,10 @@ export const ar: TranslationResources = {
changedOnDisk: "تغيّر على القرص",
vimMode: "وضع Vim ‏{{mode}}",
cursor: "السطر {{line}}، العمود {{column}}",
preview: "معاينة",
source: "المصدر",
deletedTitle: "تم حذف الملف من القرص",
checkFailedTitle: "تعذّر التحقق من الملف على القرص",
preservedDescription: "تم الاحتفاظ بالنسخة المفتوحة.",
conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.",
overwrite: "استبدال",
@@ -2190,7 +2174,6 @@ export const ar: TranslationResources = {
terminal: "الطرفية",
browser: "المتصفح",
editor: "المحرر",
files: "الملفات",
shortcuts: "الاختصارات",
integrations: "التكامل",
notifications: "الإشعارات",
@@ -2204,14 +2187,6 @@ export const ar: TranslationResources = {
vimKeybindings: "اختصارات Vim",
vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
},
files: {
title: "الملفات",
askEveryTime: "اسأل في كل مرة",
askAgain: "اسأل مجددًا",
askAgainFor: "اسأل مجددًا عن {{label}}",
unavailable: "غير موجود على مضيف متصل",
empty: "تظهر لوحات الملفات هنا عندما تضيف إحدى الإضافات لوحة",
},
notifications: {
title: "الإشعارات",
permission: "إذن الإشعارات",
+3 -28
View File
@@ -1943,31 +1943,12 @@ export const en = {
creatingAgent: "Creating agent",
},
file: {
filePanels: {
pickerTitleExtension: "Open .{{extension}} files with",
pickerTitleFilename: "Open {{filename}} with",
dontAskAgain: "Don't ask me again",
use: "Use",
openWith: "Open with…",
alwaysUseExtension: "Always use for .{{extension}}",
alwaysUseFilename: "Always use for {{filename}}",
option: "{{title}} — {{owner}}",
actions: "File actions",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Image",
source: "Source",
},
},
directoryMissing: "Workspace directory not found.",
loading: "Loading file...",
noPreview: "No preview available",
binaryPreviewUnavailable: "Binary preview unavailable",
tooLargeToDisplay: "This file is too large to display",
failedToLoad: "Failed to load file",
notFound: "File not found",
failedToLoadPreview: "Failed to load file preview",
editor: {
fileSize: "File size {{size}}",
@@ -1979,7 +1960,10 @@ export const en = {
changedOnDisk: "Changed on disk",
vimMode: "Vim mode {{mode}}",
cursor: "Line {{line}}, column {{column}}",
preview: "Preview",
source: "Source",
deletedTitle: "File deleted on disk",
checkFailedTitle: "Couldn't check file on disk",
preservedDescription: "The open copy is preserved.",
conflictDescription: "The local buffer was preserved. Choose which version to keep.",
overwrite: "Overwrite",
@@ -2177,7 +2161,6 @@ export const en = {
appearance: "Appearance",
sidebar: "Sidebar",
editor: "Editor",
files: "Files",
terminal: "Terminal",
browser: "Browser",
shortcuts: "Shortcuts",
@@ -2231,14 +2214,6 @@ export const en = {
vimKeybindings: "Vim keybindings",
vimHint: "Applies to source files on web and desktop.",
},
files: {
title: "Files",
askEveryTime: "Ask every time",
askAgain: "Ask again",
askAgainFor: "Ask again for {{label}}",
unavailable: "Not on a connected host",
empty: "File panels appear here when a plugin adds one",
},
notifications: {
title: "Notifications",
permission: "Notification permission",
+3 -28
View File
@@ -1966,31 +1966,12 @@ export const es: TranslationResources = {
creatingAgent: "Agente creador",
},
file: {
filePanels: {
pickerTitleExtension: "Abrir archivos .{{extension}} con",
pickerTitleFilename: "Abrir {{filename}} con",
dontAskAgain: "No volver a preguntar",
use: "Usar",
openWith: "Abrir con…",
alwaysUseExtension: "Usar siempre para .{{extension}}",
alwaysUseFilename: "Usar siempre para {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Acciones del archivo",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Imagen",
source: "Código fuente",
},
},
directoryMissing: "No se encontró el directorio de Workspace.",
loading: "Cargando archivo...",
noPreview: "No hay vista previa disponible",
binaryPreviewUnavailable: "Vista previa binaria no disponible",
tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo",
failedToLoad: "No se pudo cargar el archivo",
notFound: "No se encontró el archivo",
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
editor: {
fileSize: "Tamaño {{size}}",
@@ -2002,7 +1983,10 @@ export const es: TranslationResources = {
changedOnDisk: "Cambiado en disco",
vimMode: "Modo Vim {{mode}}",
cursor: "Línea {{line}}, columna {{column}}",
preview: "Vista previa",
source: "Código fuente",
deletedTitle: "Archivo eliminado del disco",
checkFailedTitle: "No se pudo comprobar el archivo en disco",
preservedDescription: "La copia abierta se ha conservado.",
conflictDescription: "Se conservó el búfer local. Elige qué versión mantener.",
overwrite: "Sobrescribir",
@@ -2202,7 +2186,6 @@ export const es: TranslationResources = {
terminal: "Terminal",
browser: "Navegador",
editor: "Editor",
files: "Archivos",
shortcuts: "Atajos",
integrations: "Integraciones",
notifications: "Notificaciones",
@@ -2216,14 +2199,6 @@ export const es: TranslationResources = {
vimKeybindings: "Atajos de Vim",
vimHint: "Se aplica a archivos fuente en web y escritorio.",
},
files: {
title: "Archivos",
askEveryTime: "Preguntar siempre",
askAgain: "Volver a preguntar",
askAgainFor: "Volver a preguntar para {{label}}",
unavailable: "No está en un host conectado",
empty: "Los paneles de archivo aparecen aquí cuando un plugin añade uno",
},
notifications: {
title: "Notificaciones",
permission: "Permiso de notificaciones",
+3 -28
View File
@@ -1964,31 +1964,12 @@ export const fr: TranslationResources = {
creatingAgent: "Création de l’agent",
},
file: {
filePanels: {
pickerTitleExtension: "Ouvrir les fichiers .{{extension}} avec",
pickerTitleFilename: "Ouvrir {{filename}} avec",
dontAskAgain: "Ne plus demander",
use: "Utiliser",
openWith: "Ouvrir avec…",
alwaysUseExtension: "Toujours utiliser pour .{{extension}}",
alwaysUseFilename: "Toujours utiliser pour {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Actions du fichier",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Image",
source: "Source",
},
},
directoryMissing: "Répertoire de l’espace de travail introuvable.",
loading: "Chargement du fichier…",
noPreview: "Aucun aperçu disponible",
binaryPreviewUnavailable: "Aperçu indisponible pour un fichier binaire",
tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché",
failedToLoad: "Impossible de charger le fichier",
notFound: "Fichier introuvable",
failedToLoadPreview: "Impossible de charger l’aperçu du fichier",
editor: {
fileSize: "Taille du fichier : {{size}}",
@@ -2000,7 +1981,10 @@ export const fr: TranslationResources = {
changedOnDisk: "Modifié sur le disque",
vimMode: "Mode Vim {{mode}}",
cursor: "Ligne {{line}}, colonne {{column}}",
preview: "Aperçu",
source: "Source",
deletedTitle: "Fichier supprimé du disque",
checkFailedTitle: "Impossible de vérifier le fichier sur le disque",
preservedDescription: "La copie ouverte est conservée.",
conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.",
overwrite: "Écraser",
@@ -2202,7 +2186,6 @@ export const fr: TranslationResources = {
terminal: "Terminal",
browser: "Navigateur",
editor: "Éditeur",
files: "Fichiers",
shortcuts: "Raccourcis",
integrations: "Intégrations",
notifications: "Notifications",
@@ -2216,14 +2199,6 @@ export const fr: TranslationResources = {
vimKeybindings: "Raccourcis Vim",
vimHint: "S’applique aux fichiers source dans l’app web et l’app de bureau.",
},
files: {
title: "Fichiers",
askEveryTime: "Toujours demander",
askAgain: "Redemander",
askAgainFor: "Redemander pour {{label}}",
unavailable: "Absent des hôtes connectés",
empty: "Les panneaux de fichier apparaissent ici quand un plugin en ajoute un",
},
notifications: {
title: "Notifications",
permission: "Autorisation des notifications",
+3 -28
View File
@@ -1937,31 +1937,12 @@ export const ja: TranslationResources = {
creatingAgent: "エージェントを作成中",
},
file: {
filePanels: {
pickerTitleExtension: ".{{extension}} ファイルを開くアプリ",
pickerTitleFilename: "{{filename}} を開くアプリ",
dontAskAgain: "今後は確認しない",
use: "使用",
openWith: "このアプリで開く…",
alwaysUseExtension: ".{{extension}} に常に使用",
alwaysUseFilename: "{{filename}} に常に使用",
option: "{{title}} — {{owner}}",
actions: "ファイルの操作",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "画像",
source: "ソース",
},
},
directoryMissing: "ワークスペースディレクトリが見つかりません。",
loading: "ファイルを読み込み中...",
noPreview: "プレビューが利用できません",
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
failedToLoad: "ファイルの読み込みに失敗しました",
notFound: "ファイルが見つかりません",
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
editor: {
fileSize: "ファイルサイズ {{size}}",
@@ -1973,7 +1954,10 @@ export const ja: TranslationResources = {
changedOnDisk: "ディスク上で変更されました",
vimMode: "Vim モード {{mode}}",
cursor: "{{line}} 行、{{column}} 列",
preview: "プレビュー",
source: "ソース",
deletedTitle: "ファイルはディスク上から削除されました",
checkFailedTitle: "ディスク上のファイルを確認できませんでした",
preservedDescription: "開いているコピーは保持されています。",
conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。",
overwrite: "上書き",
@@ -2175,7 +2159,6 @@ export const ja: TranslationResources = {
terminal: "ターミナル",
browser: "ブラウザ",
editor: "エディター",
files: "ファイル",
shortcuts: "ショートカット",
integrations: "連携",
notifications: "通知",
@@ -2189,14 +2172,6 @@ export const ja: TranslationResources = {
vimKeybindings: "Vim キーバインド",
vimHint: "Web とデスクトップのソースファイルに適用されます。",
},
files: {
title: "ファイル",
askEveryTime: "毎回確認する",
askAgain: "もう一度確認する",
askAgainFor: "{{label}} をもう一度確認する",
unavailable: "接続中のホストにありません",
empty: "プラグインがファイルパネルを追加すると、ここに表示されます",
},
notifications: {
title: "通知",
permission: "通知の権限",
+3 -28
View File
@@ -1928,31 +1928,12 @@ export const ko: TranslationResources = {
creatingAgent: "에이전트 생성 중",
},
file: {
filePanels: {
pickerTitleExtension: ".{{extension}} 파일 열기 방식",
pickerTitleFilename: "{{filename}} 열기 방식",
dontAskAgain: "다시 묻지 않기",
use: "사용",
openWith: "다음으로 열기…",
alwaysUseExtension: ".{{extension}}에 항상 사용",
alwaysUseFilename: "{{filename}}에 항상 사용",
option: "{{title}} — {{owner}}",
actions: "파일 작업",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "이미지",
source: "소스",
},
},
directoryMissing: "워크스페이스 디렉터리를 찾을 수 없습니다.",
loading: "파일 불러오는 중...",
noPreview: "사용 가능한 미리보기가 없습니다",
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
failedToLoad: "파일을 불러오지 못했습니다",
notFound: "파일을 찾을 수 없습니다",
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
editor: {
fileSize: "파일 크기 {{size}}",
@@ -1964,7 +1945,10 @@ export const ko: TranslationResources = {
changedOnDisk: "디스크에서 변경됨",
vimMode: "Vim 모드 {{mode}}",
cursor: "{{line}} 라인, {{column}} 열",
preview: "미리보기",
source: "소스",
deletedTitle: "디스크에서 파일이 삭제되었습니다.",
checkFailedTitle: "디스크의 파일을 확인할 수 없습니다.",
preservedDescription: "열린 복사본은 보존됩니다.",
conflictDescription: "로컬 버퍼가 보존되었습니다. 유지할 버전을 선택하세요.",
overwrite: "덮어쓰기",
@@ -2164,7 +2148,6 @@ export const ko: TranslationResources = {
terminal: "터미널",
browser: "브라우저",
editor: "편집기",
files: "파일",
shortcuts: "단축키",
integrations: "통합",
notifications: "알림",
@@ -2178,14 +2161,6 @@ export const ko: TranslationResources = {
vimKeybindings: "Vim 키 바인딩",
vimHint: "웹 및 데스크톱의 소스 파일에 적용됩니다.",
},
files: {
title: "파일",
askEveryTime: "매번 묻기",
askAgain: "다시 묻기",
askAgainFor: "{{label}} 다시 묻기",
unavailable: "연결된 호스트에 없음",
empty: "플러그인이 파일 패널을 추가하면 여기에 표시됩니다",
},
notifications: {
title: "알림",
permission: "알림 권한",
+3 -28
View File
@@ -1951,31 +1951,12 @@ export const ptBR: TranslationResources = {
creatingAgent: "Criando agente",
},
file: {
filePanels: {
pickerTitleExtension: "Abrir arquivos .{{extension}} com",
pickerTitleFilename: "Abrir {{filename}} com",
dontAskAgain: "Não perguntar novamente",
use: "Usar",
openWith: "Abrir com…",
alwaysUseExtension: "Sempre usar para .{{extension}}",
alwaysUseFilename: "Sempre usar para {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Ações do arquivo",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Imagem",
source: "Fonte",
},
},
directoryMissing: "Diretório do workspace não encontrado.",
loading: "Carregando arquivo...",
noPreview: "Nenhuma prévia disponível",
binaryPreviewUnavailable: "Prévia binária indisponível",
tooLargeToDisplay: "Este arquivo é grande demais para exibir",
failedToLoad: "Falha ao carregar arquivo",
notFound: "Arquivo não encontrado",
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
editor: {
fileSize: "Tamanho {{size}}",
@@ -1987,7 +1968,10 @@ export const ptBR: TranslationResources = {
changedOnDisk: "Alterado no disco",
vimMode: "Modo Vim {{mode}}",
cursor: "Linha {{line}}, coluna {{column}}",
preview: "Prévia",
source: "Fonte",
deletedTitle: "Arquivo excluído do disco",
checkFailedTitle: "Não foi possível verificar o arquivo no disco",
preservedDescription: "A cópia aberta foi preservada.",
conflictDescription: "O conteúdo local foi preservado. Escolha qual versão manter.",
overwrite: "Sobrescrever",
@@ -2187,7 +2171,6 @@ export const ptBR: TranslationResources = {
terminal: "Terminal",
browser: "Navegador",
editor: "Editor",
files: "Arquivos",
shortcuts: "Atalhos",
integrations: "Integrações",
notifications: "Notificações",
@@ -2201,14 +2184,6 @@ export const ptBR: TranslationResources = {
vimKeybindings: "Atalhos do Vim",
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
},
files: {
title: "Arquivos",
askEveryTime: "Perguntar sempre",
askAgain: "Perguntar de novo",
askAgainFor: "Perguntar de novo para {{label}}",
unavailable: "Não está em um host conectado",
empty: "Os painéis de arquivo aparecem aqui quando um plugin adiciona um",
},
notifications: {
title: "Notificações",
permission: "Permissão de notificações",
+3 -28
View File
@@ -1950,31 +1950,12 @@ export const ru: TranslationResources = {
creatingAgent: "Создание агента",
},
file: {
filePanels: {
pickerTitleExtension: "Открывать файлы .{{extension}} с помощью",
pickerTitleFilename: "Открывать {{filename}} с помощью",
dontAskAgain: "Больше не спрашивать",
use: "Использовать",
openWith: "Открыть с помощью…",
alwaysUseExtension: "Всегда использовать для .{{extension}}",
alwaysUseFilename: "Всегда использовать для {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Действия с файлом",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Изображение",
source: "Исходный текст",
},
},
directoryMissing: "Каталог рабочего пространства не найден.",
loading: "Загрузка файла...",
noPreview: "Предварительный просмотр недоступен",
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
tooLargeToDisplay: "Этот файл слишком велик для отображения",
failedToLoad: "Не удалось загрузить файл",
notFound: "Файл не найден",
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
editor: {
fileSize: "Размер файла {{size}}",
@@ -1986,7 +1967,10 @@ export const ru: TranslationResources = {
changedOnDisk: "Изменён на диске",
vimMode: "Режим Vim {{mode}}",
cursor: "Строка {{line}}, столбец {{column}}",
preview: "Предпросмотр",
source: "Исходный текст",
deletedTitle: "Файл удалён с диска",
checkFailedTitle: "Не удалось проверить файл на диске",
preservedDescription: "Открытая копия сохранена в редакторе.",
conflictDescription:
"Локальный буфер сохранён в редакторе. Выберите, какую версию оставить.",
@@ -2187,7 +2171,6 @@ export const ru: TranslationResources = {
terminal: "Терминал",
browser: "Браузер",
editor: "Редактор",
files: "Файлы",
shortcuts: "Сочетания клавиш",
integrations: "Интеграции",
notifications: "Уведомления",
@@ -2201,14 +2184,6 @@ export const ru: TranslationResources = {
vimKeybindings: "Сочетания клавиш Vim",
vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
},
files: {
title: "Файлы",
askEveryTime: "Спрашивать каждый раз",
askAgain: "Спросить снова",
askAgainFor: "Спросить снова для {{label}}",
unavailable: "Нет на подключённых хостах",
empty: "Панели файлов появятся здесь, когда плагин добавит их",
},
notifications: {
title: "Уведомления",
permission: "Разрешение на уведомления",

Some files were not shown because too many files have changed in this diff Show More