mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 00:32:45 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d369c9d36b | ||
|
|
12890f8ab0 | ||
|
|
7c4aa058ff | ||
|
|
702bc942b7 | ||
|
|
b1b5010bd2 | ||
|
|
888b54bb44 | ||
|
|
63c3bb8c42 | ||
|
|
569b34263d | ||
|
|
71cc853f7b | ||
|
|
f2dfa83e83 | ||
|
|
fb4ca20dbd | ||
|
|
4e2aa97a74 | ||
|
|
5ab9b8dcd4 | ||
|
|
4284209025 | ||
|
|
6ae87ec7e2 | ||
|
|
630e8301ad | ||
|
|
74bb3e76e4 |
@@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
|
||||
@@ -4,6 +4,9 @@ Paseo is a client-server system for monitoring and controlling local AI coding a
|
||||
|
||||
Your code never leaves your machine. Paseo is local-first.
|
||||
|
||||
Daemon-owned outbound connections and routed API scope ownership are described in
|
||||
[Connected hosts](connected-hosts.md).
|
||||
|
||||
## System overview
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Connected hosts
|
||||
|
||||
A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list.
|
||||
Connect A to B through `paseo --host A host` or `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable
|
||||
`registrationId` created before B is reachable.
|
||||
|
||||
## Ownership and authority
|
||||
|
||||
Credentials live in A's `hosts.json`, written atomically with mode `0600` on POSIX systems. Discovery returns a safe
|
||||
target description. Replacement inputs never come back through a read API. Keep this file private
|
||||
when collecting diagnostics or backing up daemon state.
|
||||
|
||||
The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry,
|
||||
including a user's disabled state. Changed input requires an explicit update. Display labels and
|
||||
access can be changed by users with `tunnel.manage`; connection and metadata changes require the
|
||||
producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again.
|
||||
Disabling retains the opt-out.
|
||||
|
||||
Each enabled registration owns one physical daemon link, shared by status monitoring and every
|
||||
routed API scope. Each scope owns an isolated virtual session on B. A derives its identity from the
|
||||
admitted origin session, producer, and logical SDK scope. B admits virtual sessions under the actual
|
||||
physical link principal, then intersects forwarded caller grants with that link's current authority.
|
||||
Changing or resuming a parent's grants closes its existing channels; reconnect re-admits demand with
|
||||
current authority. A routed API cannot select a second hop, and saving a host grants no reverse access.
|
||||
|
||||
A producer that registers a host through a routed API is qualified on B as
|
||||
`route:<B principal>:<A serverId>:<A producer>`, with each component percent-encoded. This namespace belongs to the authenticated link
|
||||
principal. An ordinary direct B consumer does not become that producer by supplying a source key.
|
||||
The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal.
|
||||
Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
|
||||
|
||||
A managed child has a qualified `{serverId, agentId}` address on every host. The parent's daemon owns membership and lifecycle intent; a destination's parent pointer is metadata. Workspace comparisons include the server ID, so matching workspace strings on different hosts do not share lifecycle ownership. Preserve the archive policy in [agent lifecycle](agent-lifecycle.md). The first child snapshot carries the owner's ledger projection even while creation is pending, because tab placement runs before creation finishes. Explicit clearing survives list and disk-cache round trips; a parent pointer or label cannot recreate membership on a capable daemon.
|
||||
|
||||
Retained app conversations bind their stores, queries, initialization and layout to origin authority as well as destination. Two routes to B therefore keep separate cached evidence and command capabilities. Provider catalog pushes belong to that same destination binding, including facts shown in the parent's track. Route restore remembers an origin ID, then reacquires current authority; persisted namespaces grant no access.
|
||||
|
||||
Durable relationships retain a stable authority reference and creation ceiling, never live credentials or execution capabilities. Recovery must resolve current authority through its owner. Derived runtime sessions cannot authorize their own recovery. Execution-scoped admissions and non-owner durable references without a supported authority resolver fail explicitly; do not recover them through the daemon owner's MCP runtime.
|
||||
|
||||
Before accepting new writes, the daemon adopts eligible pre-feature local parent labels into authoritative membership. The upgrade records its bounded candidates before qualifying agent metadata, then resumes those candidates after interruption. It never rescans later label edits. Existing membership, including forgotten entries, takes precedence; explicit null or qualified parent pointers do not establish legacy provenance. Adopted entries preserve the old daemon-owned local lifecycle, not an authenticated creator grant, and contain no creation recipe to replay. Recovery still resolves current owner authority within the retained self-only ceiling.
|
||||
|
||||
Provider-owned subagents are descriptors and timelines in `ProviderSubagentStore`, not `AgentStorage` records. They retain their provider observation/cancellation path. Internal managed agents and execution-owned records are separate stored/runtime concepts and are excluded from adoption on both sides of the relationship.
|
||||
|
||||
## Connections and recovery
|
||||
|
||||
The shared Node connector owns the target grammar and each connection attempt, including a fresh
|
||||
SSH process. SSH reaches an already running daemon. The shared client connection lifecycle owns
|
||||
physical reconnect and keepalive; the registry retains configuration, identity acceptance, and recovery
|
||||
status. Unreachable registrations remain saved. Identity is pinned when discovered. Self and duplicate
|
||||
registrations remain inspectable; a changed destination requires explicit identity replacement.
|
||||
|
||||
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
||||
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
||||
operations; requesting this feature gives update guidance.
|
||||
|
||||
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
||||
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
||||
through the usual owned-subscription contract. The ordinary client queues new requests while connecting
|
||||
within its existing deadline and rejects requests while disconnected. Connection loss clears old
|
||||
waiters and queued sends. Submitted requests with no response fail with `DaemonConnectionError`
|
||||
(`DAEMON_CONNECTION_LOST`), optional `outcome: "unknown"` and known `serverId`. Inspect the destination
|
||||
before retrying a mutation.
|
||||
Ordinary non-recoverable mutations are not automatically replayed. Existing acknowledged/idempotent
|
||||
creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission
|
||||
RPC codes pass through unchanged, and connection/recovery behavior matches direct clients.
|
||||
|
||||
Creation cancellation fences a reserved creation identity before admission. An admitted receipt can precede resource readiness and cannot be reported as canceled. Observe its current receipt and later changes through the ordinary SDK instead of replaying an uncertain prompt. Parent archive retains a pending obligation until cancellation or destination lifecycle confirmation; explicit forget removes local membership without claiming the destination changed.
|
||||
|
||||
Run journals retain submission and provider-turn identity. Restarted nonterminal evidence is interrupted, and subsequent evidence must match that turn. Idle and reconnect do not prove completion. A storage failure retains observed evidence with pending durability; a later read retries persistence. Notification recovery must preserve checkpoint identity and this durability distinction.
|
||||
|
||||
Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8
|
||||
channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to
|
||||
base64 JSON. The existing direct binary opcodes and 100 MiB message limit remain unchanged; routed
|
||||
payloads reserve space for the routing header inside that limit. Relay encryption retains its own
|
||||
existing frame limit.
|
||||
|
||||
Host links cap saved registrations, simultaneous channels, handshake lifetime, frames, physical
|
||||
output, and aggregate queued bytes. Capacity checks use the actual escaped outer JSON or binary
|
||||
frame size. Awaitable output completes when the physical send completes. An overflowing channel
|
||||
closes without closing siblings or the saved link. Routed sessions release resources on disconnect;
|
||||
subscription demand is rebuilt by its owning client on reconnect.
|
||||
|
||||
See the [SDK reference](../public-docs/sdk/reference.md#clienthosts),
|
||||
[CLI reference](../public-docs/cli.md#connected-hosts), and
|
||||
[plugin reference](../public-docs/plugins/reference.md#connected-hosts).
|
||||
@@ -31,6 +31,10 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement
|
||||
requires the producer or `access.manage`. Routed operations retain both caller and link authority,
|
||||
see [Connected hosts](connected-hosts.md#ownership-and-authority).
|
||||
|
||||
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
|
||||
|
||||
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
@@ -38178,6 +38178,7 @@
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/relay": "0.12.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -854,7 +854,8 @@ async function revealCompactChanges(page: Page) {
|
||||
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
|
||||
}
|
||||
async function expectReviewEditorBody(page: Page, body: string) {
|
||||
await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue(
|
||||
body,
|
||||
);
|
||||
const editor = page.getByRole("textbox", { name: "Review comment", exact: true });
|
||||
// A viewport resize starts the presentation handoff; exactly one editor must remain accessible.
|
||||
await expect(editor).toHaveCount(1);
|
||||
await expect(editor).toHaveValue(body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { metroTest as test, expect } from "../support/fixtures";
|
||||
import { installDeveloperToastDismissal } from "../support/developer-ui.mjs";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
async function showTransientDeveloperToast(page: Page, shadow: boolean) {
|
||||
await page.evaluate((useShadow) => {
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "error-toast";
|
||||
document.body.append(toast);
|
||||
const root = useShadow ? toast.attachShadow({ mode: "open" }) : toast;
|
||||
const button = document.createElement("button");
|
||||
button.style.cssText = "width:32px;height:32px";
|
||||
button.addEventListener("click", () => button.remove());
|
||||
root.append(button);
|
||||
}, shadow);
|
||||
}
|
||||
|
||||
async function openTransientDeveloperToast(page: Page) {
|
||||
await page.setContent("<button>Continue</button>");
|
||||
await showTransientDeveloperToast(page, false);
|
||||
await installDeveloperToastDismissal(page);
|
||||
}
|
||||
|
||||
async function navigateWithDeveloperToastDismissal(page: Page) {
|
||||
await installDeveloperToastDismissal(page);
|
||||
await page.goto("data:text/html,<button>Continue</button>");
|
||||
await showTransientDeveloperToast(page, true);
|
||||
}
|
||||
|
||||
async function inspectLayoutWhileToastDisappears(page: Page) {
|
||||
const action = page.getByRole("button", { name: "Continue", exact: true });
|
||||
const boxes = await Promise.all(Array.from({ length: 4 }, () => action.boundingBox()));
|
||||
for (const box of boxes) expect(box).not.toBeNull();
|
||||
await expect(page.locator("#error-toast").getByRole("button")).toHaveCount(0);
|
||||
await action.click();
|
||||
}
|
||||
|
||||
test("a disappearing developer toast does not stall concurrent layout checks", async ({ page }) => {
|
||||
test.setTimeout(10000);
|
||||
await openTransientDeveloperToast(page);
|
||||
await inspectLayoutWhileToastDisappears(page);
|
||||
});
|
||||
|
||||
test("developer toast dismissal survives navigation and observes Expo shadow-root updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(10000);
|
||||
await navigateWithDeveloperToastDismissal(page);
|
||||
await inspectLayoutWhileToastDisappears(page);
|
||||
await page.evaluate(() => {
|
||||
const root = document.getElementById("error-toast")!.shadowRoot!;
|
||||
const replacement = document.createElement("button");
|
||||
replacement.style.cssText = "width:32px;height:32px";
|
||||
replacement.addEventListener("click", () => replacement.remove());
|
||||
root.append(replacement);
|
||||
});
|
||||
await inspectLayoutWhileToastDisappears(page);
|
||||
});
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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,150 +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 offers Source in place of a panel no host has, and forgets it", 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).toBeEnabled({ timeout: 30_000 });
|
||||
await dropdown.click();
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,16 @@
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { openSessions, clickSessionRow } from "../support/helpers/archive-tab";
|
||||
import {
|
||||
seedFleetSubagents,
|
||||
delegateFleetThroughMcp,
|
||||
observeFleetRouteOwnership,
|
||||
readFleetCompletion,
|
||||
connectFleetDirectHost,
|
||||
openFleetParentTrack,
|
||||
readFleetAuthorityState,
|
||||
expectFleetRouteUnavailable,
|
||||
} from "../support/helpers/subagents";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
@@ -6,6 +19,8 @@ import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
|
||||
|
||||
test.use({ e2eInjectPaseoTools: true });
|
||||
|
||||
const SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
|
||||
@@ -147,3 +162,167 @@ test("a preferred chat file link still opens beside its right-pane agent", async
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a fleet child retains its origin beside direct access through collision, catalog push, reload and revocation", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const fleet = await seedFleetSubagents();
|
||||
try {
|
||||
const a = fleet.parent.serverId;
|
||||
const b = fleet.destination.serverId;
|
||||
await connectFleetDirectHost(page, fleet);
|
||||
await openFleetParentTrack(page);
|
||||
const rows = page.getByTestId(`subagents-track-row-${fleet.remote.id}`);
|
||||
await expect(rows).toHaveCount(2);
|
||||
const remote = rows.filter({ hasText: "Fleet child on B" });
|
||||
await expect(remote).toBeVisible();
|
||||
await expect(remote.getByTestId(`subagents-track-row-facts-${fleet.remote.id}`)).toHaveText(
|
||||
"Ten second stream · Low · Load test",
|
||||
);
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.status)
|
||||
.toBe("online");
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.catalogVersion)
|
||||
.toBeGreaterThan(0);
|
||||
const beforePush = await readFleetAuthorityState(page, a, b);
|
||||
await fleet.direct.providers.refresh({ cwd: fleet.workspace.repoPath });
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.catalogVersion)
|
||||
.toBeGreaterThan(beforePush.routed.catalogVersion);
|
||||
await remote.click();
|
||||
await expect(page).toHaveURL(
|
||||
(url) => url.pathname.includes(`/h/${b}/workspace/`) && url.searchParams.get("origin") === a,
|
||||
);
|
||||
await submitMessage(page, "ROUTED_FLEET_PROMPT");
|
||||
await expect
|
||||
.poll(async () => (await fleet.remote.refresh())?.agent.lastUserMessageAt)
|
||||
.not.toBeNull();
|
||||
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
|
||||
const routedUrl = page.url();
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await fleet.remote.waitForFinish();
|
||||
// Reopen direct B through ordinary history, then retain it beside the routed conversation.
|
||||
await openSessions(page);
|
||||
await clickSessionRow(page, "Fleet child on B");
|
||||
await expect(page).toHaveURL(
|
||||
(url) => url.pathname.includes(`/h/${b}/workspace/`) && !url.searchParams.has("origin"),
|
||||
);
|
||||
await openFleetParentTrack(page);
|
||||
await page
|
||||
.getByTestId(`subagents-track-row-${fleet.remote.id}`)
|
||||
.filter({ hasText: "Fleet child on B" })
|
||||
.click();
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.status)
|
||||
.toBe("online");
|
||||
await fleet.disable();
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.status)
|
||||
.not.toBe("online");
|
||||
const withdrawn = await readFleetAuthorityState(page, a, b);
|
||||
expect(withdrawn.direct.status).toBe("online");
|
||||
await fleet.direct.providers.refresh({ cwd: fleet.workspace.repoPath });
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.catalogVersion)
|
||||
.toBeGreaterThan(withdrawn.direct.catalogVersion);
|
||||
expect((await readFleetAuthorityState(page, a, b)).routed.catalogVersion).toBe(
|
||||
withdrawn.routed.catalogVersion,
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
|
||||
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
|
||||
// Direct B remains a separate usable authority; its newer evidence cannot refill A's withdrawn scope.
|
||||
await page.goto(buildHostAgentDetailRoute(b, fleet.remote.id, fleet.workspace.workspaceId));
|
||||
await submitMessage(page, "DIRECT_ONLY_AFTER_REVOCATION");
|
||||
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toBeVisible();
|
||||
await page.goto(routedUrl);
|
||||
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
|
||||
await expectFleetRouteUnavailable(page, a, b);
|
||||
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toHaveCount(0);
|
||||
const beforeRecovery = (await fleet.direct.agents.ref(fleet.remote.id).refresh())?.agent
|
||||
.lastUserMessageAt;
|
||||
await fleet.enable();
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.status)
|
||||
.toBe("online");
|
||||
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
|
||||
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toBeVisible();
|
||||
await submitMessage(page, "AFTER_REENABLE_THROUGH_A");
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await fleet.direct.agents.ref(fleet.remote.id).refresh())?.agent.lastUserMessageAt,
|
||||
)
|
||||
.not.toBe(beforeRecovery);
|
||||
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
|
||||
} finally {
|
||||
await fleet.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an agent delegates through MCP to self and B, opens both ordinary conversations and receives correlated completion", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
const counts = observeFleetRouteOwnership(page);
|
||||
const fleet = await delegateFleetThroughMcp();
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: fleet.workspace.workspaceId,
|
||||
agentId: fleet.parent.id,
|
||||
});
|
||||
console.log("Fleet ordinary parent ownership", await counts());
|
||||
await openSubagentsTrack(page);
|
||||
await expect(page.getByTestId(`subagents-track-row-${fleet.local.id}`)).toContainText(
|
||||
"MCP child on A",
|
||||
);
|
||||
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.id}`)).toContainText(
|
||||
"MCP child on B",
|
||||
);
|
||||
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.id}`)).toContainText(
|
||||
"Fleet B",
|
||||
);
|
||||
await page.getByTestId(`subagents-track-row-${fleet.local.id}`).click();
|
||||
await expect(page).toHaveURL((url) =>
|
||||
url.pathname.includes(`/h/${fleet.local.serverId}/workspace/`),
|
||||
);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await fleet.expectCompletion(fleet.local);
|
||||
await submitMessage(page, "UI_MCP_FLEET_LOCAL");
|
||||
await expect(page.getByText("UI_MCP_FLEET_LOCAL", { exact: true })).toBeVisible();
|
||||
expect(
|
||||
(await fleet.direct.agents.ref(fleet.local.id).refresh())?.agent.lastUserMessageAt,
|
||||
).toBeNull();
|
||||
await page.getByTestId(`workspace-tab-agent_${fleet.parent.id}`).click();
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${fleet.remote.id}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
(url) =>
|
||||
url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`) &&
|
||||
url.searchParams.get("origin") === fleet.parent.serverId,
|
||||
);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
console.log("Fleet destination conversation ownership", await counts());
|
||||
await fleet.expectCompletion(fleet.remote);
|
||||
await submitMessage(page, "UI_MCP_FLEET_REMOTE");
|
||||
await expect(page.getByText("UI_MCP_FLEET_REMOTE", { exact: true })).toBeVisible();
|
||||
expect(
|
||||
(await fleet.origin.agents.ref(fleet.remote.id).refresh())?.agent.lastUserMessageAt,
|
||||
).toBeNull();
|
||||
await fleet.expectCompletion(fleet.local);
|
||||
await fleet.expectCompletion(fleet.remote);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: fleet.workspace.workspaceId,
|
||||
agentId: fleet.parent.id,
|
||||
});
|
||||
await readFleetCompletion(page, fleet.local);
|
||||
await readFleetCompletion(page, fleet.remote);
|
||||
} finally {
|
||||
await fleet.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
const HIDE_DEVELOPER_TOASTS = "#error-toast { display: none !important; }";
|
||||
|
||||
/** @param {import('@playwright/test').Page} page */
|
||||
export async function hideDeveloperToasts(page) {
|
||||
// Developer chrome must not cover app actions or move the test's pointer/focus.
|
||||
// Console errors remain in the runner's logs. Hiding the toast, instead of clicking it away,
|
||||
// leaves no race with a toast that closes or is replaced between matching and clicking.
|
||||
await page.addInitScript((css) => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
document.addEventListener("DOMContentLoaded", () => document.head.append(style));
|
||||
}, HIDE_DEVELOPER_TOASTS);
|
||||
// A page attached after it loaded (Electron over CDP) runs no init script until it navigates.
|
||||
await page.addStyleTag({ content: HIDE_DEVELOPER_TOASTS });
|
||||
export async function installDeveloperToastDismissal(page) {
|
||||
// Keep developer chrome out of app actions without pointer/focus changes.
|
||||
// Locator handlers can deadlock when concurrent checks dismiss the same toast.
|
||||
const install = () => {
|
||||
let observedRoot;
|
||||
const options = { childList: true, subtree: true };
|
||||
const dismiss = () => {
|
||||
const toast = document.getElementById("error-toast");
|
||||
const root = toast?.shadowRoot ?? toast;
|
||||
if (root && root !== observedRoot) {
|
||||
observer.disconnect();
|
||||
observer.observe(document, options);
|
||||
observer.observe(root, options);
|
||||
observedRoot = root;
|
||||
}
|
||||
// Expo's toast root contains dismiss buttons; its overlay has a different ID.
|
||||
for (const button of root?.querySelectorAll("button") ?? [])
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
};
|
||||
const observer = new MutationObserver(dismiss);
|
||||
observer.observe(document, options);
|
||||
dismiss();
|
||||
};
|
||||
await page.addInitScript(install);
|
||||
await page.evaluate(install);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { hideDeveloperToasts } from "./developer-ui.mjs";
|
||||
import { installDeveloperToastDismissal } 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 hideDeveloperToasts(page);
|
||||
await installDeveloperToastDismissal(page);
|
||||
|
||||
const entries: string[] = [];
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { inspectSessionWireRequest } from "./session-wire";
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
@@ -16,11 +17,7 @@ interface SendAgentMessageRequest {
|
||||
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: Record<string, unknown>;
|
||||
};
|
||||
const request = envelope.type === "session" ? envelope.message : null;
|
||||
const request = inspectSessionWireRequest(message)?.request;
|
||||
if (
|
||||
request?.type !== "send_agent_message_request" ||
|
||||
typeof request.requestId !== "string" ||
|
||||
|
||||
@@ -74,3 +74,12 @@ export async function loadProtocolSchemas(): Promise<typeof import("@getpaseo/pr
|
||||
).href;
|
||||
return import(moduleUrl);
|
||||
}
|
||||
|
||||
export async function loadPaseoApiFactory(): Promise<
|
||||
(typeof import("@getpaseo/client"))["createPaseoApi"]
|
||||
> {
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.resolve(__dirname, "../../../../../packages/client/dist/index.js"),
|
||||
).href;
|
||||
return (await import(moduleUrl)).createPaseoApi;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { inspectSessionWireRequest } from "./session-wire";
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
@@ -71,12 +72,11 @@ function readSessionMessage(message: string | Buffer): ClientRequest | null {
|
||||
|
||||
function readClientRequest(message: string | Buffer): ClientRequest | null {
|
||||
if (typeof message !== "string") return null;
|
||||
const session = inspectSessionWireRequest(message);
|
||||
if (session) return session.request;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: ClientRequest;
|
||||
};
|
||||
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
|
||||
const envelope = JSON.parse(message) as ClientRequest;
|
||||
return envelope.type === "session" ? null : envelope;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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 ?? {};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Inspect an ordinary session request while preserving any owning transport envelopes. */
|
||||
export function inspectSessionWireRequest(message: string | Buffer): {
|
||||
request: Record<string, unknown>;
|
||||
reply(response: unknown): string;
|
||||
} | null {
|
||||
const routes: string[] = [];
|
||||
let raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
for (;;) {
|
||||
const envelope = JSON.parse(raw);
|
||||
if (envelope?.type !== "session" || !envelope.message || typeof envelope.message !== "object")
|
||||
return null;
|
||||
const request: Record<string, unknown> = envelope.message;
|
||||
if (request.type !== "hosts.route.frame.request") {
|
||||
return {
|
||||
request,
|
||||
reply(response) {
|
||||
let data = JSON.stringify({ type: "session", message: response });
|
||||
for (const subscriptionId of routes.toReversed()) {
|
||||
data = JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "hosts.route.frame",
|
||||
payload: { subscriptionId, data, binary: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
typeof request.routeId !== "string" ||
|
||||
typeof request.data !== "string" ||
|
||||
request.binary !== false
|
||||
)
|
||||
return null;
|
||||
routes.push(request.routeId);
|
||||
raw = request.data;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ const SECTION_LABELS = {
|
||||
appearance: "Appearance",
|
||||
sidebar: "Sidebar",
|
||||
editor: "Editor",
|
||||
files: "Files",
|
||||
terminal: "Terminal",
|
||||
browser: "Browser",
|
||||
shortcuts: "Shortcuts",
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { addConnectedHostAndReload } from "./hosts";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { openSessions, clickSessionRow } from "./archive-tab";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient, loadPaseoApiFactory } from "./daemon-client-loader";
|
||||
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { inspectSessionWireRequest } from "./session-wire";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
@@ -251,22 +263,19 @@ export async function rejectNextManagedSubagentArchiveRequest(
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const requestId = archiveRequestId(message, subagentId);
|
||||
if (requestId && !rejected) {
|
||||
const intercepted = archiveRequest(message, subagentId);
|
||||
if (intercepted && !rejected) {
|
||||
rejected = true;
|
||||
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
|
||||
// correlated error travels back through the real browser client and mutation rollback.
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "archive_agent_request",
|
||||
error: "Archive rejected for test",
|
||||
code: "archive_rejected",
|
||||
},
|
||||
intercepted.reply({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: intercepted.request.requestId,
|
||||
requestType: "archive_agent_request",
|
||||
error: "Archive rejected for test",
|
||||
code: "archive_rejected",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -282,28 +291,18 @@ export async function rejectNextManagedSubagentArchiveRequest(
|
||||
}
|
||||
|
||||
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
|
||||
return archiveRequestId(message, subagentId) !== null;
|
||||
return archiveRequest(message, subagentId) !== null;
|
||||
}
|
||||
|
||||
function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(rawMessage) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown; agentId?: unknown; requestId?: unknown };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "archive_agent_request" &&
|
||||
envelope.message.agentId === subagentId &&
|
||||
typeof envelope.message.requestId === "string"
|
||||
) {
|
||||
return envelope.message.requestId;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
function archiveRequest(message: WebSocketMessage, subagentId: string) {
|
||||
const inspected = inspectSessionWireRequest(message);
|
||||
if (
|
||||
inspected?.request.type === "archive_agent_request" &&
|
||||
inspected.request.agentId === subagentId &&
|
||||
typeof inspected.request.requestId === "string"
|
||||
)
|
||||
return inspected;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
|
||||
@@ -320,3 +319,354 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom
|
||||
await expect(detachButton).toBeVisible({ timeout: 30_000 });
|
||||
await detachButton.click();
|
||||
}
|
||||
|
||||
/** Real equal-ID children in equal-ID workspaces, with both route authorities independently live. */
|
||||
export async function seedFleetSubagents() {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "fleet-track-" });
|
||||
const destination = await startIsolatedHostDaemon(`fleet_${randomUUID()}`);
|
||||
const clients: DaemonClient[] = [];
|
||||
const apis: ReturnType<Awaited<ReturnType<typeof loadPaseoApiFactory>>>[] = [];
|
||||
const close = async () => {
|
||||
await Promise.allSettled(apis.map((api) => api.dispose()));
|
||||
await Promise.allSettled(clients.map((client) => client.close()));
|
||||
await destination.close();
|
||||
await workspace.cleanup();
|
||||
};
|
||||
try {
|
||||
const createApi = await loadPaseoApiFactory();
|
||||
const aClient = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "fleet-origin" });
|
||||
clients.push(aClient);
|
||||
const bClient = await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "fleet-destination",
|
||||
port: destination.port,
|
||||
});
|
||||
clients.push(bClient);
|
||||
const origin = createApi(aClient);
|
||||
const direct = createApi(bClient);
|
||||
apis.push(origin, direct);
|
||||
const registered = await origin.hosts.add({ connection: `127.0.0.1:${destination.port}` });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await origin.hosts.list()).hosts.find(
|
||||
(host) => host.registrationId === registered.registrationId,
|
||||
)?.status,
|
||||
)
|
||||
.toBe("online");
|
||||
const destinationWorkspace = await direct.workspaces.create({
|
||||
workspaceId: workspace.workspaceId,
|
||||
source: { kind: "directory", path: workspace.repoPath },
|
||||
});
|
||||
expect(destinationWorkspace.id).toBe(workspace.workspaceId);
|
||||
const config = { provider: "mock/ten-second-stream", modeId: "load-test" };
|
||||
const parent = await origin.workspaces
|
||||
.ref(workspace.workspaceId)
|
||||
.agents.create({ title: "Fleet parent", config });
|
||||
const agentId = randomUUID();
|
||||
const local = await parent.subagents.create({ agentId, title: "Fleet child on A", config });
|
||||
const remote = await parent.subagents.create({
|
||||
agentId,
|
||||
serverId: destination.serverId,
|
||||
workspaceId: destinationWorkspace.id,
|
||||
title: "Fleet child on B",
|
||||
config,
|
||||
});
|
||||
return {
|
||||
workspace,
|
||||
destination,
|
||||
origin,
|
||||
direct,
|
||||
parent,
|
||||
local,
|
||||
remote,
|
||||
disable: () => origin.hosts.update(registered.registrationId, { enabled: false }),
|
||||
enable: () => origin.hosts.update(registered.registrationId, { enabled: true }),
|
||||
close,
|
||||
};
|
||||
} catch (error) {
|
||||
await close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectFleetDirectHost(
|
||||
page: Page,
|
||||
fleet: Awaited<ReturnType<typeof seedFleetSubagents>>,
|
||||
) {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: fleet.workspace.workspaceId,
|
||||
agentId: fleet.parent.id,
|
||||
});
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: fleet.destination.serverId,
|
||||
port: fleet.destination.port,
|
||||
label: "Fleet B",
|
||||
});
|
||||
await page.evaluate(
|
||||
(host) => localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([host])),
|
||||
buildSeededHost({
|
||||
serverId: fleet.destination.serverId,
|
||||
endpoint: `127.0.0.1:${fleet.destination.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
label: "Fleet B",
|
||||
}),
|
||||
);
|
||||
await page.goto(
|
||||
buildHostAgentDetailRoute(
|
||||
fleet.destination.serverId,
|
||||
fleet.remote.id,
|
||||
fleet.workspace.workspaceId,
|
||||
),
|
||||
);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openFleetParentTrack(page: Page) {
|
||||
await openSessions(page);
|
||||
await clickSessionRow(page, "Fleet parent");
|
||||
await openSubagentsTrack(page);
|
||||
}
|
||||
|
||||
export async function readFleetAuthorityState(
|
||||
page: Page,
|
||||
originServerId: string,
|
||||
serverId: string,
|
||||
) {
|
||||
return page.evaluate(
|
||||
({ originServerId: sourceId, serverId: destinationId }) => {
|
||||
const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as HostRuntimeStore;
|
||||
const read = (origin: string) => {
|
||||
const binding = runtime
|
||||
.getAuthorityBindings()
|
||||
.find(
|
||||
(candidate) =>
|
||||
candidate.reference.originServerId === origin &&
|
||||
candidate.reference.serverId === destinationId,
|
||||
);
|
||||
const queries =
|
||||
binding?.queries
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey: ["providersSnapshot", destinationId] }) ?? [];
|
||||
return {
|
||||
status: binding?.runtime.getSnapshot(destinationId)?.connectionStatus ?? "absent",
|
||||
hydrated: binding?.sessionStore.getState().sessions[destinationId]?.hasHydratedWorkspaces,
|
||||
workspaceIds: [
|
||||
...(binding?.sessionStore.getState().sessions[destinationId]?.workspaces.keys() ?? []),
|
||||
],
|
||||
catalogVersion: Math.max(0, ...queries.map((query) => query.state.dataUpdatedAt)),
|
||||
};
|
||||
};
|
||||
return { direct: read(destinationId), routed: read(sourceId) };
|
||||
},
|
||||
{ originServerId, serverId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectFleetRouteUnavailable(
|
||||
page: Page,
|
||||
originServerId: string,
|
||||
serverId: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, originServerId, serverId)).routed.status)
|
||||
.toMatch(/^(offline|error)$/);
|
||||
const unavailable = page.getByText(/^(Cannot reach .+|.+ is offline)$/).filter({ visible: true });
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(unavailable.or(composer)).toBeVisible();
|
||||
if (await composer.isVisible()) {
|
||||
if (await composer.isEditable()) {
|
||||
await composer.fill("MUST_NOT_USE_DIRECT_AUTHORITY");
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
|
||||
} else await expect(composer).toBeDisabled();
|
||||
} else
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Primary children are created by the agent's real MCP endpoint, never the seed SDK. */
|
||||
export async function delegateFleetThroughMcp() {
|
||||
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
||||
const { StreamableHTTPClientTransport } =
|
||||
await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
||||
const { getE2EDaemonPort } = await import("./daemon-port");
|
||||
const { z } = await import("zod");
|
||||
const workspace = await seedWorkspace({ repoPrefix: "mcp-fleet-journey-" });
|
||||
const destination = await startIsolatedHostDaemon(`fleet_${randomUUID()}`);
|
||||
const createApi = await loadPaseoApiFactory();
|
||||
const aClient = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "mcp-fleet-origin" });
|
||||
const bClient = await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "mcp-fleet-observer",
|
||||
port: destination.port,
|
||||
});
|
||||
const origin = createApi(aClient);
|
||||
const direct = createApi(bClient);
|
||||
const mcp = new Client({ name: "fleet-delegating-agent", version: "1.0.0" });
|
||||
const close = async () => {
|
||||
await mcp.close();
|
||||
await Promise.allSettled([origin.dispose(), direct.dispose()]);
|
||||
await Promise.allSettled([aClient.close(), bClient.close()]);
|
||||
await destination.close();
|
||||
await workspace.cleanup();
|
||||
};
|
||||
const call = async (name: string, args: Record<string, unknown> = {}) => {
|
||||
const result = await mcp.callTool({ name, arguments: args });
|
||||
expect(result, `${name} result`).not.toMatchObject({ isError: true });
|
||||
return z.record(z.string(), z.unknown()).parse(result.structuredContent);
|
||||
};
|
||||
try {
|
||||
const config = { provider: "mock/ten-second-stream", modeId: "load-test" };
|
||||
const parent = await origin.workspaces
|
||||
.ref(workspace.workspaceId)
|
||||
.agents.create({ title: "Fleet MCP parent", config });
|
||||
const registered = await origin.hosts.add({
|
||||
connection: `127.0.0.1:${destination.port}`,
|
||||
label: "Fleet B",
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await origin.hosts.list()).hosts.find(
|
||||
(host) => host.registrationId === registered.registrationId,
|
||||
)?.status,
|
||||
)
|
||||
.toBe("online");
|
||||
await mcp.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents?callerAgentId=${parent.id}`),
|
||||
),
|
||||
);
|
||||
const hosts = await call("list_hosts");
|
||||
expect(hosts).toMatchObject({
|
||||
hosts: expect.arrayContaining([
|
||||
expect.objectContaining({ serverId: destination.serverId, label: "Fleet B" }),
|
||||
]),
|
||||
});
|
||||
const target = await call("create_workspace", {
|
||||
serverId: destination.serverId,
|
||||
isolation: "local",
|
||||
path: workspace.repoPath,
|
||||
title: "Fleet B workspace",
|
||||
});
|
||||
const targetWorkspaceId = z.string().parse(target.workspaceId);
|
||||
expect(await call("list_workspaces", { serverId: destination.serverId })).toMatchObject({
|
||||
workspaces: expect.arrayContaining([
|
||||
expect.objectContaining({ workspaceId: targetWorkspaceId }),
|
||||
]),
|
||||
});
|
||||
const createChild = async (serverId: string, workspaceId: string, title: string) => {
|
||||
const result = await call("create_agent", {
|
||||
serverId,
|
||||
workspaceId,
|
||||
title,
|
||||
provider: config.provider,
|
||||
settings: { modeId: config.modeId },
|
||||
initialPrompt: `Complete ${title}`,
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
const agentId = z.string().parse(result.agentId);
|
||||
const submissionId = z.string().parse(result.submissionId);
|
||||
expect(await call("get_agent_status", { serverId, agentId })).toMatchObject({ serverId });
|
||||
expect(await call("get_agent_activity", { serverId, agentId })).toMatchObject({ serverId });
|
||||
expect(await call("list_agents", { serverId })).toMatchObject({
|
||||
serverId,
|
||||
agents: expect.arrayContaining([expect.objectContaining({ id: agentId })]),
|
||||
});
|
||||
return { id: agentId, serverId, workspaceId, submissionId, title };
|
||||
};
|
||||
const local = await createChild(parent.serverId, workspace.workspaceId, "MCP child on A");
|
||||
const remote = await createChild(destination.serverId, targetWorkspaceId, "MCP child on B");
|
||||
// Opposite-host collisions are unrelated fixture agents. Neither primary child is seeded.
|
||||
await direct.agents.create({
|
||||
agentId: local.id,
|
||||
cwd: workspace.repoPath,
|
||||
title: "Unrelated collision on B",
|
||||
config,
|
||||
});
|
||||
await origin.agents.create({
|
||||
agentId: remote.id,
|
||||
cwd: workspace.repoPath,
|
||||
title: "Unrelated collision on A",
|
||||
config,
|
||||
});
|
||||
expect((await parent.subagents.list()).rows.map((row) => row.child)).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ serverId: local.serverId, agentId: local.id },
|
||||
{ serverId: remote.serverId, agentId: remote.id },
|
||||
]),
|
||||
);
|
||||
return {
|
||||
workspace,
|
||||
destination,
|
||||
parent,
|
||||
local,
|
||||
remote,
|
||||
origin,
|
||||
direct,
|
||||
close,
|
||||
async expectCompletion(child: typeof local) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const timeline = await parent.timeline.refetch();
|
||||
return timeline.entries
|
||||
.flatMap(({ item }) =>
|
||||
item.type === "tool_call" && item.agentMessage ? [item.agentMessage] : [],
|
||||
)
|
||||
.filter(
|
||||
(message) =>
|
||||
message.event === "finished" &&
|
||||
message.sender.address?.serverId === child.serverId &&
|
||||
message.text.includes(child.submissionId),
|
||||
);
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.toHaveLength(1);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFleetCompletion(
|
||||
page: Page,
|
||||
child: { title: string; submissionId: string },
|
||||
) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: new RegExp(`Agent finished.*${child.title}`) }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** Records observed requests and runtime-owned sessions without opening extra scopes. */
|
||||
export function observeFleetRouteOwnership(page: Page) {
|
||||
const requests: Record<string, number> = {};
|
||||
let sockets = 0;
|
||||
page.on("websocket", (socket) => {
|
||||
sockets++;
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
const request = inspectSessionWireRequest(payload)?.request;
|
||||
if (!request || typeof request.type !== "string") return;
|
||||
if (request.subscribe !== undefined || request.type === "hosts.route.open.request")
|
||||
requests[request.type] = (requests[request.type] ?? 0) + 1;
|
||||
});
|
||||
});
|
||||
return async () => ({
|
||||
sockets,
|
||||
cumulativeRequests: { ...requests },
|
||||
bindings: await page.evaluate(() => {
|
||||
const registry = Reflect.get(globalThis, "__paseoHostRuntimeStore") as HostRuntimeStore;
|
||||
return registry.getAuthorityBindings().map((binding) => {
|
||||
const id = binding.reference.serverId;
|
||||
const session = binding.sessionStore.getState().sessions[id];
|
||||
return {
|
||||
reference: binding.reference,
|
||||
status: binding.runtime.getSnapshot(id)?.connectionStatus,
|
||||
session: session?.client !== null && session?.client !== undefined,
|
||||
workspaceReplica: session?.hasHydratedWorkspaces === true,
|
||||
timelineReplica: session?.viewedTimelineSync !== undefined,
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HostAuthoritySessions } from "@/runtime/host-authority-sessions";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
|
||||
@@ -313,6 +314,7 @@ function HostSessionManager() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<HostAuthoritySessions />
|
||||
{hosts.map((daemon) => (
|
||||
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
|
||||
))}
|
||||
|
||||
@@ -19,13 +19,20 @@ export default function HostRouteLayout() {
|
||||
}
|
||||
|
||||
function KnownHostRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
origin?: string | string[];
|
||||
}>();
|
||||
const hosts = useHosts();
|
||||
const hostRegistryStatus = useHostRegistryStatus();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId : null;
|
||||
const startupRoute = resolveStartupRoute({
|
||||
route: { kind: "host", serverId: routeServerId },
|
||||
route: {
|
||||
kind: "host",
|
||||
serverId: routeServerId,
|
||||
...(typeof params.origin === "string" ? { originServerId: params.origin } : {}),
|
||||
},
|
||||
startupBlocker: bootstrapState.startupBlocker,
|
||||
hostRegistryStatus,
|
||||
hosts,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
@@ -5,21 +6,34 @@ import { useFetchQuery } from "@/data/query";
|
||||
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
|
||||
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { useHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
buildSettingsHostRoute,
|
||||
withHostRouteAuthority,
|
||||
} from "@/utils/host-routes";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
const { serverId, origin } = useLocalSearchParams<{ serverId?: string; origin?: string }>();
|
||||
const reference = useMemo(
|
||||
() => ({ originServerId: origin ?? serverId ?? "", serverId: serverId ?? "" }),
|
||||
[origin, serverId],
|
||||
);
|
||||
if (!serverId) return null;
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<HostAgentReadyRouteContent />
|
||||
<HostAuthorityRoute reference={reference}>
|
||||
<HostAgentReadyRouteContent />
|
||||
</HostAuthorityRoute>
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function HostAgentReadyRouteContent() {
|
||||
const router = useRouter();
|
||||
const runtime = useHostRuntimeStore();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
@@ -40,10 +54,8 @@ function HostAgentReadyRouteContent() {
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!serverId || !agentId || agentWorkspaceId) return;
|
||||
void getHostRuntimeStore()
|
||||
.prepareAgentRoute(serverId, agentId)
|
||||
.catch(() => undefined);
|
||||
}, [agentId, agentWorkspaceId, serverId]);
|
||||
void runtime.prepareAgentRoute(serverId, agentId).catch(() => undefined);
|
||||
}, [agentId, agentWorkspaceId, serverId, runtime]);
|
||||
const shouldLookupAgent = Boolean(
|
||||
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
||||
);
|
||||
@@ -106,11 +118,23 @@ function HostAgentReadyRouteContent() {
|
||||
handledNavigationRef.current = navigationKey;
|
||||
|
||||
if (resolution.kind === "resolved") {
|
||||
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId: resolution.workspaceId,
|
||||
authority: runtime.authorityFor(serverId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
|
||||
}, [agentId, resolution, router, serverId]);
|
||||
router.replace(
|
||||
resolution.kind === "invalid"
|
||||
? ("/" as Href)
|
||||
: (withHostRouteAuthority(
|
||||
buildHostRootRoute(serverId),
|
||||
runtime.authorityFor(serverId),
|
||||
) as Href),
|
||||
);
|
||||
}, [agentId, resolution, router, serverId, runtime]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
if (resolution.kind === "lookupError") {
|
||||
@@ -118,21 +142,28 @@ function HostAgentReadyRouteContent() {
|
||||
return;
|
||||
}
|
||||
if (serverId) {
|
||||
void getHostRuntimeStore().runProbeCycleNow(serverId);
|
||||
void runtime.runProbeCycleNow(serverId);
|
||||
}
|
||||
}, [lookupQuery, resolution.kind, serverId]);
|
||||
}, [lookupQuery, resolution.kind, serverId, runtime]);
|
||||
const handleManageHost = useCallback(() => {
|
||||
if (serverId) {
|
||||
router.push(buildSettingsHostRoute(serverId));
|
||||
router.push(buildSettingsHostRoute(runtime.authorityFor(serverId).originServerId));
|
||||
}
|
||||
}, [router, serverId]);
|
||||
}, [router, serverId, runtime]);
|
||||
const handleBack = useCallback(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
|
||||
}, [router, serverId]);
|
||||
router.replace(
|
||||
serverId
|
||||
? (withHostRouteAuthority(
|
||||
buildHostRootRoute(serverId),
|
||||
runtime.authorityFor(serverId),
|
||||
) as Href)
|
||||
: ("/" as Href),
|
||||
);
|
||||
}, [router, serverId, runtime]);
|
||||
|
||||
if (
|
||||
resolution.kind === "waitingForHost" ||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Redirect } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
|
||||
import {
|
||||
workspaceSelectionAuthority,
|
||||
type ActiveWorkspaceSelection,
|
||||
} from "@/stores/last-workspace-selection";
|
||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||
import { useHostRouteServerId } from "@/navigation/host-route-context";
|
||||
import {
|
||||
resolveHostIndexRoute,
|
||||
@@ -13,10 +19,31 @@ import {
|
||||
|
||||
export default function HostIndexRoute() {
|
||||
const serverId = useHostRouteServerId();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const remembered = useLastWorkspaceSelection();
|
||||
const { origin } = useLocalSearchParams<{ origin?: string }>();
|
||||
const selection = useMemo(
|
||||
() =>
|
||||
remembered?.serverId === serverId
|
||||
? { ...remembered, ...(origin ? { authority: { originServerId: origin } } : {}) }
|
||||
: null,
|
||||
[remembered, serverId, origin],
|
||||
);
|
||||
if (!selection) return <HostIndexContent workspaceSelection={null} />;
|
||||
return (
|
||||
<HostAuthorityRoute reference={workspaceSelectionAuthority(selection)}>
|
||||
<HostIndexContent workspaceSelection={selection} />
|
||||
</HostAuthorityRoute>
|
||||
);
|
||||
}
|
||||
|
||||
function HostIndexContent({
|
||||
workspaceSelection,
|
||||
}: {
|
||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||
}) {
|
||||
const serverId = useHostRouteServerId();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceSelectionWorkspaceId =
|
||||
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
|
||||
const workspaceSelectionWorkspaceId = workspaceSelection?.workspaceId ?? null;
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
|
||||
import { workspaceSelectionAuthority } from "@/stores/last-workspace-selection";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigation } from "expo-router/react-navigation";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { useGlobalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import {
|
||||
@@ -24,16 +26,12 @@ import {
|
||||
resolveWorkspaceDeckEntries,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
} from "@/screens/workspace/workspace-deck-retention";
|
||||
import {
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseWorkspaceOpenIntent,
|
||||
type WorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { parseWorkspaceOpenIntent, type WorkspaceOpenIntent } from "@/utils/host-routes";
|
||||
import {
|
||||
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
|
||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
|
||||
} from "@/utils/host-route-browser";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useWorkspaceTabNavigation } from "@/workspace-tabs/navigation";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
|
||||
@@ -94,24 +92,34 @@ export default function HostWorkspaceIndexRoute() {
|
||||
}
|
||||
|
||||
function HostWorkspaceRouteContent() {
|
||||
const { open } = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
return (
|
||||
<WorkspaceDeck
|
||||
recoveryRequested={parseWorkspaceOpenIntent(getParamValue(open))?.kind === "agent"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceOpenIntentBoundary({
|
||||
selection,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { prepareWorkspaceTab } = useWorkspaceTabNavigation();
|
||||
const navigation = useNavigation();
|
||||
const rootNavigationState = useRootNavigationState();
|
||||
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
||||
const consumedIntentRef = useRef<string | null>(null);
|
||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
}>();
|
||||
const globalParams = useGlobalSearchParams<{
|
||||
open?: string | string[];
|
||||
}>();
|
||||
const serverId = getParamValue(params.serverId);
|
||||
const workspaceValue = getParamValue(params.workspaceId);
|
||||
const workspaceId = workspaceValue
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const { serverId, workspaceId } = selection;
|
||||
const openValue = active ? getParamValue(globalParams.open) : "";
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
|
||||
@@ -178,6 +186,7 @@ function HostWorkspaceRouteContent() {
|
||||
openIntent,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
prepareWorkspaceTab,
|
||||
serverId,
|
||||
workspaceId,
|
||||
]);
|
||||
@@ -190,7 +199,7 @@ function HostWorkspaceRouteContent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
@@ -274,7 +283,20 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceDeckEntry({
|
||||
function WorkspaceDeckEntry(props: {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
active: boolean;
|
||||
recoveryRequested: boolean;
|
||||
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||
}) {
|
||||
return (
|
||||
<HostAuthorityRoute reference={workspaceSelectionAuthority(props.selection)}>
|
||||
<WorkspaceDeckEntryContent {...props} />
|
||||
</HostAuthorityRoute>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceDeckEntryContent({
|
||||
selection,
|
||||
active,
|
||||
recoveryRequested,
|
||||
@@ -308,12 +330,14 @@ function WorkspaceDeckEntry({
|
||||
active={active}
|
||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||
>
|
||||
<WorkspaceScreen
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={active}
|
||||
recoveryRequested={active && recoveryRequested}
|
||||
/>
|
||||
<WorkspaceOpenIntentBoundary selection={selection} active={active}>
|
||||
<WorkspaceScreen
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={active}
|
||||
recoveryRequested={active && recoveryRequested}
|
||||
/>
|
||||
</WorkspaceOpenIntentBoundary>
|
||||
</RetainedPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,10 +39,14 @@ export default function Index() {
|
||||
hosts,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
|
||||
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
|
||||
workspaceExists: workspaceSelectionExists,
|
||||
}),
|
||||
// COMPAT(unboundStartupSelection): added in v0.12, remove after 2027-04-20.
|
||||
// Qualified restore reacquires its origin at the host boundary before proving absence.
|
||||
workspaceSelectionStatus: workspaceSelection?.authority
|
||||
? "unknown"
|
||||
: resolveWorkspaceSelectionStatus({
|
||||
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
|
||||
workspaceExists: workspaceSelectionExists,
|
||||
}),
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
@@ -50,7 +50,8 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
options: [{ id: "high", label: "High" }],
|
||||
},
|
||||
],
|
||||
parentAgentId: null,
|
||||
parent: null,
|
||||
managedRelationship: null,
|
||||
labels: {},
|
||||
...overrides,
|
||||
turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null },
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { RewindMode } from "./use-rewind-capabilities";
|
||||
import { useRewindComposerRestore } from "./composer-restore";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSessionStoreApi } from "@/stores/session-store";
|
||||
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
interface UseRewindAgentMutationInput {
|
||||
serverId?: string;
|
||||
@@ -26,6 +26,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
isPending: boolean;
|
||||
} {
|
||||
const toast = useToast();
|
||||
const sessionStore = useSessionStoreApi();
|
||||
const runtime = useHostRuntimeStore();
|
||||
const { t } = useTranslation();
|
||||
const composerRestore = useRewindComposerRestore();
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
@@ -36,12 +38,10 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
await input.client.rewindAgent(input.agentId, input.messageId, mode);
|
||||
if (mode !== "files") {
|
||||
const cursor = input.serverId
|
||||
? useSessionStore
|
||||
.getState()
|
||||
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
|
||||
? sessionStore.getState().sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
|
||||
: undefined;
|
||||
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
|
||||
await runtime.fetchAgentTimeline(input.serverId, input.agentId, {
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
||||
|
||||
@@ -5,9 +5,13 @@ import {
|
||||
resolveComposerAttachmentSubmitFormat,
|
||||
splitComposerAttachmentsForSubmit,
|
||||
} from "@/composer/attachments/submit";
|
||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import {
|
||||
isActiveCreateFlowForDraft,
|
||||
useCreateFlowStore,
|
||||
useCreateFlowStoreApi,
|
||||
} from "@/stores/create-flow-store";
|
||||
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSessionStoreApi } from "@/stores/session-store";
|
||||
import {
|
||||
createUserMessage,
|
||||
type StreamItem,
|
||||
@@ -122,6 +126,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
onCreateError,
|
||||
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
|
||||
const { t } = useTranslation();
|
||||
const sessionStore = useSessionStoreApi();
|
||||
const flowStore = useCreateFlowStoreApi();
|
||||
const [localMachine, dispatch] = useReducer(
|
||||
reducer<TDraftAgent>,
|
||||
initialAttempt,
|
||||
@@ -235,6 +241,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
images: attempt.images,
|
||||
attachments: attempt.attachments,
|
||||
}),
|
||||
sessionStore,
|
||||
);
|
||||
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
|
||||
}
|
||||
@@ -255,6 +262,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
},
|
||||
[
|
||||
createRequest,
|
||||
sessionStore,
|
||||
draftId,
|
||||
getPendingServerId,
|
||||
markPendingCreateLifecycle,
|
||||
@@ -268,7 +276,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
|
||||
const handleCreateFromInput = useCallback(
|
||||
async ({ text, attachments, cwd }: SubmitContext) => {
|
||||
const existing = useCreateFlowStore.getState().pendingByDraftId[draftId];
|
||||
const existing = flowStore.getState().pendingByDraftId[draftId];
|
||||
if (
|
||||
isSubmitting ||
|
||||
isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId })
|
||||
@@ -285,7 +293,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
throw error;
|
||||
}
|
||||
const supportsForgeSearch =
|
||||
useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
|
||||
sessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
|
||||
true;
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
|
||||
format: resolveComposerAttachmentSubmitFormat({
|
||||
@@ -342,6 +350,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
draftId,
|
||||
getPendingServerId,
|
||||
isSubmitting,
|
||||
flowStore,
|
||||
sessionStore,
|
||||
onCreateStart,
|
||||
runCreateAttempt,
|
||||
setPendingCreateAttempt,
|
||||
|
||||
@@ -243,7 +243,8 @@ function buildDraftAgentSnapshot(input: {
|
||||
model,
|
||||
features: composerState.agentControls.features,
|
||||
thinkingOptionId,
|
||||
parentAgentId: null,
|
||||
parent: null,
|
||||
managedRelationship: null,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import type { ComposerTextSource } from "./text-source";
|
||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||
import { useStore } from "zustand";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
@@ -55,7 +55,11 @@ import {
|
||||
} from "@/composer/agent-controls";
|
||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
selectAgentTurnPresentation,
|
||||
useSessionStore,
|
||||
useSessionStoreApi,
|
||||
} from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
import { useFileDrop } from "@/components/file-drop/use-file-drop";
|
||||
import type { DroppedItem } from "@/components/file-drop/types";
|
||||
@@ -1264,6 +1268,8 @@ function ComposerContentImpl({
|
||||
const mode = resolveComposerInputMode(inputMode);
|
||||
const { t } = useTranslation();
|
||||
const buttonIconSize = resolveComposerButtonIconSize();
|
||||
const sessionStore = useSessionStoreApi();
|
||||
const runtime = useHostRuntimeStore();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||
@@ -1560,19 +1566,27 @@ function ComposerContentImpl({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
}),
|
||||
encodeImages,
|
||||
submission: createMessageSubmissionWriter(serverId),
|
||||
submission: createMessageSubmissionWriter(serverId, sessionStore),
|
||||
activeTurnBehavior,
|
||||
activeTurnId:
|
||||
activeTurnBehavior === "steer"
|
||||
? (selectAgentTurnPresentation(
|
||||
useSessionStore.getState().sessions[serverId],
|
||||
sessionStore.getState().sessions[serverId],
|
||||
targetAgentId,
|
||||
).turnId ?? undefined)
|
||||
: undefined,
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
};
|
||||
}, [appSettings.sendBehavior, client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
|
||||
}, [
|
||||
appSettings.sendBehavior,
|
||||
client,
|
||||
onAttentionPromptSend,
|
||||
serverId,
|
||||
sessionStore,
|
||||
supportsForgeSearch,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitMessageRef.current = onSubmitMessage;
|
||||
@@ -1603,10 +1617,10 @@ function ComposerContentImpl({
|
||||
|
||||
const queueWriter = useMemo<QueueWriter>(
|
||||
() => ({
|
||||
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
|
||||
read: (id) => sessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
|
||||
write: (updater) => setQueuedMessages(serverId, updater),
|
||||
}),
|
||||
[serverId, setQueuedMessages],
|
||||
[serverId, sessionStore, setQueuedMessages],
|
||||
);
|
||||
|
||||
const queueMessage = useCallback(
|
||||
@@ -1875,26 +1889,23 @@ function ComposerContentImpl({
|
||||
const handleCancelAgent = useCallback(() => {
|
||||
const targetAgentId = agentIdRef.current;
|
||||
const cancellation = cancelComposerAgent({
|
||||
client,
|
||||
client: client
|
||||
? { cancelAgent: (id) => runtime.cancelAgent({ serverId, agentId: id }) }
|
||||
: null,
|
||||
agentId: targetAgentId,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
});
|
||||
if (!cancellation) return;
|
||||
const requestId = getHostRuntimeStore().beginAgentCancellation(serverId, targetAgentId);
|
||||
void cancellation
|
||||
.catch((error) => {
|
||||
const message = resolveErrorMessage(error);
|
||||
if (message && message.trim().length > 0) {
|
||||
toastErrorRef.current(message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
getHostRuntimeStore().settleAgentCancellation(serverId, targetAgentId, requestId);
|
||||
});
|
||||
void cancellation.catch((error) => {
|
||||
const message = resolveErrorMessage(error);
|
||||
if (message && message.trim().length > 0) {
|
||||
toastErrorRef.current(message);
|
||||
}
|
||||
});
|
||||
messageInputRef.current?.focus();
|
||||
}, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]);
|
||||
}, [client, runtime, isAgentRunning, isCancellingAgent, isConnected, serverId]);
|
||||
|
||||
const focusMessageInputForKeyboardAction = useCallback(() => {
|
||||
focusMessageInputWithPlatformStrategy(messageInputRef);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { createSessionStore, useSessionStore } from "@/stores/session-store";
|
||||
import type { UserMessageItem } from "@/types/stream";
|
||||
import { createMessageSubmissionWriter } from "./writer";
|
||||
|
||||
@@ -40,10 +40,45 @@ afterEach(() => {
|
||||
test("tracks submissions when canonical prompts are supported", () => {
|
||||
initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: true });
|
||||
|
||||
createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID).begin(
|
||||
createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID, useSessionStore).begin(
|
||||
"agent-1",
|
||||
submittedMessage("canonical-only-message"),
|
||||
);
|
||||
|
||||
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]);
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"writes and rejects only in its captured authority store (tracked=%s)",
|
||||
(tracked) => {
|
||||
initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: tracked });
|
||||
const scoped = createSessionStore();
|
||||
scoped.getState().initializeSession(CANONICAL_ONLY_SERVER_ID, null);
|
||||
scoped.getState().updateSessionServerInfo(CANONICAL_ONLY_SERVER_ID, {
|
||||
serverId: CANONICAL_ONLY_SERVER_ID,
|
||||
hostname: null,
|
||||
version: "0.12.0",
|
||||
features: { canonicalSubmittedPrompts: tracked },
|
||||
});
|
||||
const writer = createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID, scoped);
|
||||
writer.begin("agent-1", submittedMessage("scoped-message"));
|
||||
const session = scoped.getState().sessions[CANONICAL_ONLY_SERVER_ID];
|
||||
if (tracked)
|
||||
expect(
|
||||
session.messageSubmissions.get("agent-1")?.map((submission) => submission.clientMessageId),
|
||||
).toEqual(["scoped-message"]);
|
||||
else
|
||||
expect(
|
||||
session.agentStreamTail
|
||||
.get("agent-1")
|
||||
?.map((item) => (item.kind === "user_message" ? item.clientMessageId : null)),
|
||||
).toEqual(["scoped-message"]);
|
||||
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual([]);
|
||||
expect(
|
||||
useSessionStore
|
||||
.getState()
|
||||
.sessions[CANONICAL_ONLY_SERVER_ID]?.agentStreamTail.get("agent-1") ?? [],
|
||||
).toEqual([]);
|
||||
expect(writer.reject("agent-1", "scoped-message")).toBe("rejected");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,31 +1,37 @@
|
||||
import type { MessageSubmissionWriter } from "@/composer/actions";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { SessionStoreApi } from "@/stores/session-store";
|
||||
import {
|
||||
appendSubmittedUserMessage,
|
||||
removeSubmittedUserMessage,
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
|
||||
function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
function appendUntrackedSubmission(
|
||||
store: SessionStoreApi,
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
message: UserMessageItem,
|
||||
) {
|
||||
const session = store.getState().sessions[serverId];
|
||||
if (!session) return;
|
||||
const tail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const head = session.agentStreamHead.get(agentId) ?? [];
|
||||
useSessionStore
|
||||
store
|
||||
.getState()
|
||||
.setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message }));
|
||||
}
|
||||
|
||||
function removeUntrackedSubmission(
|
||||
store: SessionStoreApi,
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
clientMessageId: string,
|
||||
): void {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const session = store.getState().sessions[serverId];
|
||||
if (!session) return;
|
||||
const tail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const head = session.agentStreamHead.get(agentId) ?? [];
|
||||
useSessionStore
|
||||
store
|
||||
.getState()
|
||||
.setAgentStreamState(
|
||||
serverId,
|
||||
@@ -34,32 +40,37 @@ function removeUntrackedSubmission(
|
||||
);
|
||||
}
|
||||
|
||||
function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
|
||||
function createUntrackedMessageSubmissionWriter(
|
||||
serverId: string,
|
||||
store: SessionStoreApi,
|
||||
): MessageSubmissionWriter {
|
||||
return {
|
||||
begin: (agentId, message) => appendUntrackedSubmission(serverId, agentId, message),
|
||||
begin: (agentId, message) => appendUntrackedSubmission(store, serverId, agentId, message),
|
||||
accept: () => undefined,
|
||||
reject: (agentId, clientMessageId) => {
|
||||
removeUntrackedSubmission(serverId, agentId, clientMessageId);
|
||||
removeUntrackedSubmission(store, serverId, agentId, clientMessageId);
|
||||
return "rejected";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
|
||||
export function createMessageSubmissionWriter(
|
||||
serverId: string,
|
||||
store: SessionStoreApi,
|
||||
): MessageSubmissionWriter {
|
||||
const supportsTrackedMessageSubmissions =
|
||||
useSessionStore.getState().sessions[serverId]?.serverInfo?.features
|
||||
?.canonicalSubmittedPrompts === true;
|
||||
store.getState().sessions[serverId]?.serverInfo?.features?.canonicalSubmittedPrompts === true;
|
||||
if (!supportsTrackedMessageSubmissions) {
|
||||
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6.
|
||||
return createUntrackedMessageSubmissionWriter(serverId);
|
||||
return createUntrackedMessageSubmissionWriter(serverId, store);
|
||||
}
|
||||
return {
|
||||
begin: (agentId, message) =>
|
||||
useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message),
|
||||
store.getState().beginAgentMessageSubmission(serverId, agentId, message),
|
||||
accept: (agentId, clientMessageId) =>
|
||||
useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId),
|
||||
store.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId),
|
||||
reject: (agentId, clientMessageId) =>
|
||||
useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
|
||||
store.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,6 +78,7 @@ export function handoffCreatedAgentMessageSubmission(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
message: UserMessageItem,
|
||||
store: SessionStoreApi,
|
||||
): boolean {
|
||||
return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
|
||||
return store.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useRef, ReactNode, useCallback, useEffect } from "react";
|
||||
import { Buffer } from "buffer";
|
||||
import { AppState } from "react-native";
|
||||
import { observeOpenWorkspaceAgentIds } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
observeOpenWorkspaceAgentIds,
|
||||
useWorkspaceLayoutStoreApi,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
@@ -27,36 +30,35 @@ import {
|
||||
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
|
||||
import type { AudioPlaybackSource } from "@/audio";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
toDaemonServerInfo,
|
||||
useSessionStore,
|
||||
useSessionStoreApi,
|
||||
type SessionState,
|
||||
} from "@/stores/session-store";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
|
||||
import {
|
||||
getInitKey,
|
||||
getInitDeferred,
|
||||
createInitDeferred,
|
||||
rejectInitDeferred,
|
||||
} from "@/utils/agent-initialization";
|
||||
import { getInitKey } from "@/utils/agent-initialization";
|
||||
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
|
||||
import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store";
|
||||
import { resyncProviderSubagents, useProviderSubagentStoreApi } from "@/subagents/provider-store";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
export type {
|
||||
Agent,
|
||||
ExplorerEntry,
|
||||
ExplorerFile,
|
||||
ExplorerEntryKind,
|
||||
ExplorerFileKind,
|
||||
ExplorerEncoding,
|
||||
AgentFileExplorerState,
|
||||
} from "@/stores/session-store";
|
||||
|
||||
@@ -198,6 +200,10 @@ export function SessionProvider(props: SessionProviderProps) {
|
||||
}
|
||||
|
||||
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
|
||||
const sessionStore = useSessionStoreApi();
|
||||
const providerStore = useProviderSubagentStoreApi();
|
||||
const layoutStore = useWorkspaceLayoutStoreApi();
|
||||
const runtime = useHostRuntimeStore();
|
||||
const { t } = useTranslation();
|
||||
const voiceRuntime = useVoiceRuntimeOptional();
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
@@ -262,7 +268,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
notification?: AgentAttentionNotificationPayload;
|
||||
}) => {
|
||||
const appState = appStateRef.current;
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const session = sessionStore.getState().sessions[serverId];
|
||||
const attentionFocusedAgentId = session?.focusedAgentId ?? null;
|
||||
if (params.reason === "error") {
|
||||
return;
|
||||
@@ -306,7 +312,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
data: notification.data,
|
||||
});
|
||||
},
|
||||
[serverId],
|
||||
[serverId, sessionStore],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -374,7 +380,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
|
||||
]);
|
||||
if (turnLiveness.length > 0) {
|
||||
getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness);
|
||||
runtime.applyAgentTurnLiveness(serverId, agentId, turnLiveness);
|
||||
}
|
||||
sync.enqueueStreamEvent(agentId, {
|
||||
event: streamEvent,
|
||||
@@ -392,14 +398,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
void requestTimelineReplacement(
|
||||
{
|
||||
fetchAgentTimeline: (agentId, request) =>
|
||||
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request),
|
||||
runtime.fetchAgentTimeline(serverId, agentId, request),
|
||||
},
|
||||
message.payload.agentId,
|
||||
).catch((error: unknown) => {
|
||||
console.warn("[Session] timeline replacement refresh failed", { serverId, error });
|
||||
});
|
||||
};
|
||||
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
|
||||
const sync = runtime.createViewedTimelineOwner(serverId, {
|
||||
observe: (agentIds) => {
|
||||
const observation = client.observeTimeline(agentIds);
|
||||
observation.subscribe({
|
||||
@@ -413,7 +419,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
},
|
||||
readCursor: (agentId) => {
|
||||
const timeline = selectAgentTimelineState(
|
||||
useSessionStore.getState().sessions[serverId],
|
||||
sessionStore.getState().sessions[serverId],
|
||||
agentId,
|
||||
);
|
||||
return timeline.status === "synced" && timeline.range
|
||||
@@ -421,15 +427,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
: undefined;
|
||||
},
|
||||
fetchPage: async (agentId, request) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const session = sessionStore.getState().sessions[serverId];
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced";
|
||||
if (shouldInitialize) {
|
||||
if (!getInitDeferred(initKey)) {
|
||||
const deferred = createInitDeferred(initKey, request.direction ?? "tail");
|
||||
if (!sessionStore.initializations.getInitDeferred(initKey)) {
|
||||
const deferred = sessionStore.initializations.createInitDeferred(
|
||||
initKey,
|
||||
request.direction ?? "tail",
|
||||
);
|
||||
void deferred.promise.catch(() => undefined);
|
||||
}
|
||||
refreshAgentInitializationTimeout({
|
||||
store: sessionStore,
|
||||
key: initKey,
|
||||
agentId,
|
||||
setAgentInitializing,
|
||||
@@ -437,21 +447,29 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setAgentInitializing(agentId, true);
|
||||
}
|
||||
try {
|
||||
const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request);
|
||||
if (shouldInitialize && getInitDeferred(initKey)) {
|
||||
refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing });
|
||||
const page = await runtime.fetchAgentTimeline(serverId, agentId, request);
|
||||
if (shouldInitialize && sessionStore.initializations.getInitDeferred(initKey)) {
|
||||
refreshAgentInitializationTimeout({
|
||||
store: sessionStore,
|
||||
key: initKey,
|
||||
agentId,
|
||||
setAgentInitializing,
|
||||
});
|
||||
}
|
||||
return page;
|
||||
} catch (error) {
|
||||
if (shouldInitialize) {
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(initKey, error instanceof Error ? error : new Error(String(error)));
|
||||
sessionStore.initializations.rejectInitDeferred(
|
||||
initKey,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
fetchLatestTail: (agentId) =>
|
||||
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()),
|
||||
runtime.fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()),
|
||||
reportError: (error) => {
|
||||
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
|
||||
},
|
||||
@@ -463,8 +481,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
viewedTimelineSyncRef.current = sync;
|
||||
setViewedTimelineSync(serverId, sync);
|
||||
sync.setActive(getIsAppVisible(appStateRef.current));
|
||||
const stopObservingOpenChats = observeOpenWorkspaceAgentIds(serverId, (agentIds) =>
|
||||
sync.replaceOpenTabAgentIds(agentIds),
|
||||
const stopObservingOpenChats = observeOpenWorkspaceAgentIds(
|
||||
serverId,
|
||||
(agentIds) => sync.replaceOpenTabAgentIds(agentIds),
|
||||
layoutStore,
|
||||
);
|
||||
|
||||
return () => {
|
||||
@@ -475,7 +495,17 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setViewedTimelineSync(serverId, null);
|
||||
sync.dispose();
|
||||
};
|
||||
}, [client, serverId, setInitializingAgents, setViewedTimelineSync, voiceRuntime]);
|
||||
}, [
|
||||
client,
|
||||
serverId,
|
||||
sessionStore,
|
||||
runtime,
|
||||
sessionStore.initializations,
|
||||
layoutStore,
|
||||
setInitializingAgents,
|
||||
setViewedTimelineSync,
|
||||
voiceRuntime,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
viewedTimelineSyncRef.current?.setConnected(isConnected);
|
||||
@@ -516,11 +546,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
const unsubProviderSubagentUpdate = feeds.subscribe({
|
||||
snapshot: () => {
|
||||
void resyncProviderSubagents(client, serverId);
|
||||
void resyncProviderSubagents(client, serverId, providerStore);
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "agent.provider_subagents.update") return;
|
||||
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
|
||||
providerStore.getState().applyUpdate(serverId, message.payload);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -706,6 +736,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
clearAgentStreamHead,
|
||||
setInitializingAgents,
|
||||
setAgents,
|
||||
providerStore,
|
||||
setPendingPermissions,
|
||||
notifyAgentAttention,
|
||||
updateSessionServerInfo,
|
||||
|
||||
@@ -28,7 +28,8 @@ function createAgent(status: Agent["status"]): Agent {
|
||||
title: "Agent",
|
||||
cwd: "/tmp",
|
||||
model: null,
|
||||
parentAgentId: null,
|
||||
parent: null,
|
||||
managedRelationship: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
export const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const queryClient = createQueryClient();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
+15
-6
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
-1
@@ -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}>
|
||||
+3
-17
@@ -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
@@ -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,
|
||||
},
|
||||
}));
|
||||
+41
-8
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,98 +0,0 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Platform, Text, View } from "react-native";
|
||||
import { WebView } from "react-native-webview";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const frameStyle = { flex: 1, backgroundColor: "white" };
|
||||
const originWhitelist = ["*"];
|
||||
|
||||
/** What Android's WebView shows inline. It has no viewer for PDF or other documents. */
|
||||
const ANDROID_INLINE_EXTENSIONS = new Set([
|
||||
"html",
|
||||
"htm",
|
||||
"xhtml",
|
||||
"xml",
|
||||
"svg",
|
||||
"txt",
|
||||
"json",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"bmp",
|
||||
"avif",
|
||||
]);
|
||||
|
||||
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;
|
||||
// The host names a file URL after the file, so its extension is the document's type.
|
||||
const extension = urlExtension(url);
|
||||
if (Platform.OS === "android" && !ANDROID_INLINE_EXTENSIONS.has(extension)) {
|
||||
return <UnsupportedDocument extension={extension} testID={testID} />;
|
||||
}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UnsupportedDocument({ extension, testID }: { extension: string; testID?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.centerState} testID={testID}>
|
||||
<Text style={styles.mutedText}>
|
||||
{t("panels.file.documentUnsupported", { type: extension.toUpperCase() })}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function urlExtension(url: string): string {
|
||||
const path = url.split(/[?#]/)[0];
|
||||
const name = path.slice(path.lastIndexOf("/") + 1);
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot < 0 ? "" : name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[4],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
mutedText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -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,195 +0,0 @@
|
||||
import {
|
||||
PluginFileConflictError,
|
||||
PluginFileDeletedError,
|
||||
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["type"][] = [];
|
||||
handle.file.watch((event) => events.push(event.type));
|
||||
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("rejects a write over a deleted file it has not observed yet", async () => {
|
||||
const { session, handle, file } = await openFile();
|
||||
await file.readText();
|
||||
handle.setObserving(false);
|
||||
session.remove("notes.md");
|
||||
|
||||
await expect(file.writeText("mine\n")).rejects.toBeInstanceOf(PluginFileDeletedError);
|
||||
await expect(file.writeText("mine\n", { force: true })).rejects.toBeInstanceOf(
|
||||
PluginFileDeletedError,
|
||||
);
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,413 +0,0 @@
|
||||
import {
|
||||
PluginFileConflictError,
|
||||
PluginFileDeletedError,
|
||||
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({ type: "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({ type: "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;
|
||||
}
|
||||
if (disk.status === "missing") throw new PluginFileDeletedError();
|
||||
throw new PluginFileConflictError();
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
@@ -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 { PluginFileDeletedError, 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 a PluginFileDeletedError", 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).toBeInstanceOf(
|
||||
PluginFileDeletedError,
|
||||
);
|
||||
});
|
||||
|
||||
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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user