mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 17:22:47 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d972f67c3f | ||
|
|
55a7590952 | ||
|
|
793b16c0df | ||
|
|
91d749f82c | ||
|
|
6376f2bf40 | ||
|
|
9d908d678b | ||
|
|
4ef4a95a42 |
@@ -44,6 +44,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) | Relay E2EE modes: Noise NK mapping, record framing, negotiation, the `requireProtectedSecurity` flag, close codes |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [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 |
|
||||
|
||||
+5
-5
@@ -21,8 +21,8 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
||||
|
||||
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
|
||||
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
|
||||
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
|
||||
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds.
|
||||
3. When the phone connects via the relay, it generates fresh ephemeral keys and sends an `e2ee_hello` message. The daemon will not process any application messages until this handshake completes.
|
||||
4. Updated peers run a Noise `NK` handshake (`Noise_NK_25519_ChaChaPoly_SHA256`) keyed to the daemon's static key: the `e2ee_hello` carries the first Noise message and the daemon's `e2ee_ready` carries the second. Every later frame is a counter-nonced ChaCha20-Poly1305 record. When either side predates this, the session falls back to the legacy construction: a Curve25519 ECDH shared key with XSalsa20-Poly1305 (NaCl `box`) and a random `[24-byte nonce][ciphertext]` bundle. Both modes negotiate `binaryCiphertext`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) has the exact mapping.
|
||||
|
||||
The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.
|
||||
|
||||
@@ -34,9 +34,9 @@ The daemon requires a valid cryptographic handshake before processing any comman
|
||||
|
||||
- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
|
||||
- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
|
||||
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
||||
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
||||
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
|
||||
- **Read your traffic** — After the handshake every message is encrypted: ChaCha20-Poly1305 in protected mode, XSalsa20-Poly1305 (NaCl box) in legacy mode
|
||||
- **Forge messages** — Both constructions are authenticated encryption; a tampered message is rejected in either mode
|
||||
- **Replay old messages** — In protected mode the daemon contributes a fresh ephemeral key to every session and every record carries a strictly increasing counter, so a recorded session cannot be replayed into a new one and a duplicated, reordered, or reflected record closes the session. Legacy mode has neither property: it uses random nonces, and a daemon that still admits legacy clients accepts a recorded legacy transcript (hello, credentials, commands) as a new session. Set `daemon.relay.requireProtectedSecurity: true` to refuse legacy clients once every client you use has updated.
|
||||
|
||||
### Trust model
|
||||
|
||||
|
||||
@@ -74,6 +74,13 @@ server to capability-aware sessions. It deliberately does not use the broad recu
|
||||
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
|
||||
not retain non-Git directories.
|
||||
|
||||
Workspace git and forge runtime data belongs to one daemon-level, directory-keyed store.
|
||||
Workspace descriptors, checkout diff subscriptions, and script metadata read the last known
|
||||
snapshot from that store, so reconnecting clients receive it before refresh completes.
|
||||
Refcounted git watches feed the store through snapshot-updated events; their lifetime controls
|
||||
refresh only. Session cleanup and workspace archive retain the data. Removing the final active
|
||||
workspace for a directory clears it. The store lives in memory and resets on daemon restart.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
@@ -175,9 +182,9 @@ Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305
|
||||
- Updated peers negotiate a Noise `NK` session with per-record counters; peers on either side that predate it fall back to the legacy NaCl `box` construction. [docs/relay-endpoint-security.md](relay-endpoint-security.md) owns the mapping, negotiation, and the `daemon.relay.requireProtectedSecurity` flag
|
||||
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Client and daemon channels with identical API (`openClientSecureChannel`, `openDaemonSecureChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
- New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
|
||||
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
|
||||
|
||||
@@ -26,6 +26,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Relay endpoint security
|
||||
|
||||
The relay carries daemon sessions between a client and a daemon that cannot reach each other directly. The relay is untrusted, so the two endpoints secure the session themselves. This doc owns the protected construction, its mapping onto Paseo's handshake and record flow, the negotiation rules, and the enforcement flag. The threat model lives in [SECURITY.md](../SECURITY.md); the code is `packages/relay/src/secure-channel.ts`.
|
||||
|
||||
Fixes [#359](https://github.com/getpaseo/paseo/issues/359): the legacy construction has no replay protection. A recorded legacy hello plus records replays onto a fresh daemon channel, and duplicated records are delivered twice.
|
||||
|
||||
## Two modes
|
||||
|
||||
| Mode | Construction | Replay protection |
|
||||
| ----------- | ------------------------------------------------ | -------------------------------- |
|
||||
| `legacy` | X25519 + NaCl `box`, random nonces | none |
|
||||
| `protected` | `Noise_NK_25519_ChaChaPoly_SHA256` (revision 34) | counters, fresh daemon ephemeral |
|
||||
|
||||
Two updated peers always negotiate `protected`. An old peer on either side falls to `legacy` while the daemon permits it. Everything in `packages/relay/src/encrypted-channel.ts` is the legacy construction and is unchanged.
|
||||
|
||||
The shared entry points are `openClientSecureChannel` and `openDaemonSecureChannel`. Callers get a `SecureChannel` and ask it `securityMode()`; nothing outside the module branches on the construction.
|
||||
|
||||
## Noise mapping
|
||||
|
||||
- **Pattern:** NK. The daemon's static key is the existing `$PASEO_HOME/daemon-keypair.json` X25519 key that pairing already distributes. There is no second daemon identity.
|
||||
- **Prologue:** the ASCII bytes `paseo-relay-e2ee/1`.
|
||||
- **Primitives:** `@noble/curves` x25519, `@noble/ciphers` ChaCha20-Poly1305, `@noble/hashes` SHA-256 and HMAC. The state machine in `packages/relay/src/noise/` is our own implementation of the spec and is checked against vendored third-party vectors in `noise/fixtures/`.
|
||||
- **Message 1** (`-> e, es`) travels inside the legacy hello: `{ type: "e2ee_hello", key, capabilities, protected: { v: 1, message: <base64> } }`. `key` is a separate legacy ephemeral so an old daemon still completes a legacy handshake. The Noise payload is the offered parameters.
|
||||
- **Message 2** (`<- e, ee`) travels inside the ready: `{ type: "e2ee_ready", protected: { v: 1, message: <base64> } }`. Its payload is the selected parameters. The outer `capabilities` field is ignored in protected mode because it is not authenticated.
|
||||
- **Parameters payload:** two bytes, `[0x01 version][flags]`. Flag bit 0 is `binaryCiphertext`. Unknown versions, reserved flag bits, and a selection the client did not offer fail the handshake.
|
||||
- **Split:** the initiator sends with the first cipher state and receives with the second. The daemon is the mirror. Nonces are 64-bit little-endian in a 12-byte nonce, reserved synchronously before any await, never wrapped. A receiver advances its counter only after the tag verifies.
|
||||
|
||||
Nothing else is bound into the handshake. The outer JSON, the relay URL, and session credentials are not authenticated by Noise. Credentials travel in the application `hello` after message 2 verified the daemon.
|
||||
|
||||
## Records
|
||||
|
||||
Each application frame becomes one record of one or more Noise transport messages:
|
||||
|
||||
```
|
||||
chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data
|
||||
wire record = (u16 BE ciphertextLength || ciphertext)+
|
||||
```
|
||||
|
||||
`kind` is `0x00` text or `0x01` binary. `flags` bit 0 marks the first chunk and bit 1 the last. `data` is at most 65513 bytes per chunk, so a chunk never exceeds the Noise message limit. The chunk count is `max(1, ceil(totalLength / 65513))`.
|
||||
|
||||
The receiver rejects a record whose metadata disagrees between chunks, whose non-final chunks are not full, whose byte total differs from `totalLength`, or that has zero chunks, trailing bytes, reserved flags, or an invalid kind. Partial plaintext never reaches the application. Transport AD is empty, as the Noise spec writes it; framing authenticity comes from the metadata being inside the ciphertext. `packages/relay/src/protected-records.ts` owns this.
|
||||
|
||||
Frame kind is preserved on the wire the same way as legacy: text records are base64 WebSocket text, binary records are WebSocket binary when `binaryCiphertext` was negotiated.
|
||||
|
||||
## Retries and failures
|
||||
|
||||
- The client resends its hello every second until a ready arrives. The daemon caches the exact hello text and ready text for the channel. An identical hello gets the identical ready and counters keep running. A different hello closes the channel (`1008`, transcript mismatch). A ready after the channel is open is ignored.
|
||||
- A fresh daemon channel always generates a fresh `e`, so a recorded hello and its records fail authentication when replayed later, including after a daemon restart.
|
||||
- A present but invalid `protected` field fails the handshake. There is no fallback to legacy once protected was attempted.
|
||||
- Any record failure (bad tag, duplicate, reordered, reflected, malformed) closes the channel and nothing from that record is delivered. The daemon closes with `1011`; the client closes with `4003`, because a browser `WebSocket.close` accepts only `1000` and `3000`-`4999`.
|
||||
- A record header is checked against the bytes actually received before any buffer is allocated for it, so a header claiming gigabytes costs nothing.
|
||||
- Keys are cleared on close as a best effort.
|
||||
|
||||
## Negotiation and admission
|
||||
|
||||
The daemon advertises `server_info.features.relayProtectedSecurity: true` on every session, whatever mode that session negotiated. A relay client that ended up in `legacy` with such a daemon closes with `4002` before the client marks itself connected, restores subscriptions, or flushes queued commands. The check lives in the relay E2EE transport, so it also covers the Node connector; direct connections never see it.
|
||||
|
||||
The app pins `minimumSecurityMode: "protected"` on every relay connection to a daemon public key after the first protected handshake with that key, or when the pairing offer carries `minimumSecurityMode`. A pinned client passes `minimumMode: "protected"` to the handshake and refuses a legacy ready before sending credentials. The pin belongs to the daemon identity, not to a relay address: re-importing the same key through any relay keeps it, a different key is a different connection, and connection failures never clear it.
|
||||
|
||||
The handshake result is read in one place, `HostRuntimeController` when the host's active client reports connected. Every client reaches that point, whether the controller created it or adopted it from a pairing or startup probe, so no client constructor carries a persistence callback. A probe that negotiated protected but was never adopted pins nothing; the pin lands when the host first goes online.
|
||||
|
||||
Within one client process the floor also rises on its own. `DaemonClient` and the Node host connector remember a protected result and require it on every later attempt, so a reconnect after a relay drop cannot be answered in legacy mode even before the pin is persisted.
|
||||
|
||||
The daemon refuses a legacy client with close `1008` and reason `protected endpoint security required`, and logs `relay_e2ee_handshake_rejected`. That close code stops at the relay: both relays replace a daemon-side close with their own (the wrangler relay sends `1012 Server disconnected`), so an old client sees an ordinary disconnect and keeps retrying. The only client-detectable failure is the client's own `4002`, which stops reconnecting and tells the user to update.
|
||||
|
||||
The client learns its own `4002` and `4003` from the relay E2EE transport, which reports the close the moment the channel decides it. A socket close event arrives later and the channel's error callback earlier; neither is what `DaemonClient` acts on, because acting on the error would start a reconnect before the code is known.
|
||||
|
||||
## Enforcement flag
|
||||
|
||||
`daemon.relay.requireProtectedSecurity` in `config.json` (also `relay.requireProtectedSecurity` through the config RPC) makes the daemon refuse legacy relay clients. Default `false`. It is a local daemon setting with no relay-server counterpart.
|
||||
|
||||
- It is read when each hello arrives, so toggling it affects the next admission without a restart. Sessions already admitted in legacy mode stay up until they disconnect; turning the flag on does not close them. To finish enforcement, restart the daemon or disconnect its relay sessions after enabling the flag: every reconnect is then a new admission and legacy clients are refused.
|
||||
- While it is on, pairing offers carry `minimumSecurityMode: "protected"` and `relay://` URIs carry `security=protected`, so new clients pin before their first handshake.
|
||||
- Direct and local connections are outside the flag.
|
||||
|
||||
Flipping the default is a separate decision, tentatively reviewed about 30 days after the compatibility release. Nothing in the code expires on a date.
|
||||
|
||||
## Known limitation
|
||||
|
||||
While a daemon accepts legacy clients, a transcript recorded from any legacy session (hello, credentials, commands) can still be replayed to it. Pinning on the client does not change that, because the attacker replays the old client's transcript, not the pinned client's. The only remedy is enabling the flag on that daemon.
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/relay/src/noise/noise.test.ts`: vendored vectors, replay and out-of-order rejection at the cipher-state level.
|
||||
- `packages/relay/src/protected-records.test.ts`: framing round trips and every malformed-record rule.
|
||||
- `packages/relay/src/secure-channel.test.ts`: both modes, mixed versions, policy rejection, retries, recorded-transcript replay, downgrade attempts.
|
||||
- `packages/server/src/server/websocket/secure-relay-ingress.test.ts`: daemon admission and the flag.
|
||||
- `packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts`: real daemon through the wrangler relay. Set `PASEO_RELAY_E2E_PORT` to run the same cases against an already-running relay such as the Elixir relay in Docker.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-DkB70/NH4SriKZ6hMDhI7XfwkqjGfb9e1zC3dxjBaTg=
|
||||
sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
|
||||
|
||||
Generated
+42
@@ -8805,6 +8805,45 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
|
||||
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
|
||||
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -42184,6 +42223,9 @@
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.13.0-beta.1",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -21,8 +21,10 @@ async function completeSubmittedTurn(
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(userMessage).toBeVisible();
|
||||
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expectAgentIdle(page);
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
return userMessage;
|
||||
|
||||
@@ -25,6 +25,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByRole("menuitem")).toHaveText([
|
||||
/^Grouping/,
|
||||
/^Sort/,
|
||||
/^Title/,
|
||||
"Show",
|
||||
"Project",
|
||||
|
||||
@@ -32,6 +32,7 @@ test.describe("Sidebar project filter", () => {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByRole("menuitem")).toHaveText([
|
||||
/^Grouping/,
|
||||
/^Sort/,
|
||||
/^Title/,
|
||||
"Show",
|
||||
"Project",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
async function rowTestIds(rows: Locator) {
|
||||
@@ -57,7 +58,19 @@ async function quickDragFirstRowAfterSecond(
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
|
||||
}
|
||||
|
||||
test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({
|
||||
async function dragWithoutReordering(rows: Locator): Promise<void> {
|
||||
const before = await rowTestIds(rows);
|
||||
const source = await visibleBoundingBox(rows.nth(0));
|
||||
const target = await visibleBoundingBox(rows.nth(1));
|
||||
const page = rows.page();
|
||||
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(before);
|
||||
}
|
||||
|
||||
test("projects and pinned chats reorder while ordinary workspaces stay newest first", async ({
|
||||
page,
|
||||
}) => {
|
||||
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
|
||||
@@ -87,12 +100,13 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra
|
||||
);
|
||||
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
|
||||
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
|
||||
await quickDragFirstRowAfterSecond(
|
||||
page.locator(
|
||||
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
|
||||
),
|
||||
pressWorkspaceRow,
|
||||
const ordinaryRows = page.locator(
|
||||
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
|
||||
);
|
||||
await expect
|
||||
.poll(() => rowTestIds(ordinaryRows))
|
||||
.toEqual([secondWorkspaceTestId, firstWorkspaceTestId]);
|
||||
await dragWithoutReordering(ordinaryRows);
|
||||
|
||||
await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true);
|
||||
await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true);
|
||||
@@ -108,3 +122,243 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra
|
||||
await secondProject.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Sort/ }).click();
|
||||
await page.getByRole("menuitem", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
async function reconnectSidebar(page: Page): Promise<void> {
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
}
|
||||
|
||||
async function expectNoWorkspaceSortChoice(page: Page): Promise<void> {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function promptWorkspace(seed: Awaited<ReturnType<typeof seedWorkspace>>, agentId: string) {
|
||||
await seed.client.sendAgentMessage(agentId, "sidebar ordering check");
|
||||
expect((await seed.client.waitForFinish(agentId, 30_000)).status).toBe("idle");
|
||||
}
|
||||
|
||||
test("Created stays stable, Last prompt follows human submissions and survives tab archive", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const seed = await seedWorkspace({ repoPrefix: "sidebar-sort-" });
|
||||
try {
|
||||
const newer = await seed.client.createWorkspace({
|
||||
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
|
||||
title: "Newer workspace",
|
||||
});
|
||||
if (!newer.workspace) throw new Error(newer.error ?? "Workspace creation failed");
|
||||
const agent = await seed.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: seed.repoPath,
|
||||
workspaceId: seed.workspaceId,
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
title: "Ordering agent",
|
||||
});
|
||||
const newerAgent = await seed.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: seed.repoPath,
|
||||
workspaceId: newer.workspace.id,
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
title: "Newer agent",
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
const oldId = `sidebar-workspace-row-${getServerId()}:${seed.workspaceId}`;
|
||||
const newId = `sidebar-workspace-row-${getServerId()}:${newer.workspace.id}`;
|
||||
const rows = page.locator(`[data-testid="${oldId}"], [data-testid="${newId}"]`);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
|
||||
await promptWorkspace(seed, agent.id);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
|
||||
await selectWorkspaceSort(page, "Last prompt");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
|
||||
await promptWorkspace(seed, newerAgent.id);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
|
||||
await promptWorkspace(seed, agent.id);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
|
||||
await seed.client.archiveAgent(agent.id);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
|
||||
await reconnectSidebar(page);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await expectNoWorkspaceSortChoice(page);
|
||||
} finally {
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function seedCompactProject() {
|
||||
const seed = await seedWorkspace({
|
||||
repoPrefix: "sidebar-visibility-",
|
||||
title: "Oldest workspace",
|
||||
});
|
||||
const ids = [seed.workspaceId];
|
||||
for (let index = 2; index <= 12; index += 1) {
|
||||
const created = await seed.client.createWorkspace({
|
||||
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
|
||||
title: `Workspace ${index}`,
|
||||
});
|
||||
if (!created.workspace) throw new Error(created.error ?? "Workspace creation failed");
|
||||
ids.unshift(created.workspace.id);
|
||||
}
|
||||
const agent = await seed.client.createAgent({
|
||||
provider: "mock",
|
||||
model: "e2e-fast-stream",
|
||||
modeId: "load-test",
|
||||
cwd: seed.repoPath,
|
||||
workspaceId: seed.workspaceId,
|
||||
title: "Overflow agent",
|
||||
});
|
||||
return { seed, ids, agent };
|
||||
}
|
||||
|
||||
async function expectWorkspaceOrder(rows: Locator, ids: string[]) {
|
||||
await expect
|
||||
.poll(() => rowTestIds(rows))
|
||||
.toEqual(ids.map((id) => `sidebar-workspace-row-${getServerId()}:${id}`));
|
||||
}
|
||||
|
||||
async function revealWorkspaces(list: Locator, action: "Show more" | "Show less") {
|
||||
await list.getByLabel(action, { exact: true }).click();
|
||||
}
|
||||
|
||||
async function selectProjectGrouping(page: Page) {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Group/ }).click();
|
||||
await page.getByTestId("sidebar-grouping-project").click();
|
||||
}
|
||||
|
||||
type CompactProject = Awaited<ReturnType<typeof seedCompactProject>>;
|
||||
|
||||
function compactProjectRows(page: Page, seed: CompactProject["seed"]) {
|
||||
const list = page.getByTestId(`sidebar-workspace-list-${seed.projectKey}`);
|
||||
return {
|
||||
rows: list.locator('[data-testid^="sidebar-workspace-row-"]'),
|
||||
group: list.locator(".."),
|
||||
project: page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seed.projectKey)}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function expectActiveOverflowTransitions(page: Page, { seed, ids, agent }: CompactProject) {
|
||||
const { rows } = compactProjectRows(page, seed);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
|
||||
await seed.client.sendAgentMessage(agent.id, "show active overflow");
|
||||
await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]);
|
||||
await seed.client.waitForFinish(agent.id, 15_000);
|
||||
await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]);
|
||||
await seed.client.clearWorkspaceAttention(seed.workspaceId);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
}
|
||||
|
||||
async function expectRevealSurvivesSortingAndCollapse(
|
||||
page: Page,
|
||||
{ seed, ids, agent }: CompactProject,
|
||||
) {
|
||||
const { rows, group, project } = compactProjectRows(page, seed);
|
||||
const retainedRows = await rows.elementHandles();
|
||||
await revealWorkspaces(group, "Show more");
|
||||
await expectWorkspaceOrder(rows, ids);
|
||||
for (const [index, retained] of retainedRows.entries()) {
|
||||
expect(
|
||||
await rows.nth(index).evaluate((current, previous) => current === previous, retained),
|
||||
).toBe(true);
|
||||
}
|
||||
await project.click();
|
||||
await expect(rows).toHaveCount(0);
|
||||
await project.click();
|
||||
await expectWorkspaceOrder(rows, ids);
|
||||
await seed.client.sendAgentMessage(agent.id, "expanded overflow");
|
||||
await seed.client.waitForFinish(agent.id, 15_000);
|
||||
await seed.client.clearWorkspaceAttention(seed.workspaceId);
|
||||
await expectWorkspaceOrder(rows, ids);
|
||||
await selectWorkspaceSort(page, "Last prompt");
|
||||
await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 11)]);
|
||||
const revealedRow = await rows.nth(8).elementHandle();
|
||||
await revealWorkspaces(group, "Show less");
|
||||
await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 6)]);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
expect(await revealedRow!.evaluate((row) => row.isConnected)).toBe(false);
|
||||
await reconnectSidebar(page);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
}
|
||||
|
||||
async function expectStatusBucketsRetainActiveOverflow(
|
||||
page: Page,
|
||||
{ seed, ids, agent }: CompactProject,
|
||||
) {
|
||||
const { rows } = compactProjectRows(page, seed);
|
||||
await selectSidebarStatusGrouping(page);
|
||||
const done = page.getByTestId("sidebar-status-group-rows-done");
|
||||
const doneRows = done.locator('[data-testid^="sidebar-workspace-row-"]');
|
||||
await expect(doneRows).toHaveCount(7);
|
||||
await revealWorkspaces(done, "Show more");
|
||||
await expect(doneRows).toHaveCount(12);
|
||||
await seed.client.sendAgentMessage(agent.id, "status overflow");
|
||||
await seed.client.waitForFinish(agent.id, 15_000);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("sidebar-status-group-rows-attention")
|
||||
.getByText("Oldest workspace", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(doneRows).toHaveCount(11);
|
||||
await seed.client.clearWorkspaceAttention(seed.workspaceId);
|
||||
await expect(doneRows).toHaveCount(12);
|
||||
await revealWorkspaces(done, "Show less");
|
||||
await expect(doneRows).toHaveCount(7);
|
||||
const activeAgents: Array<{ id: string }> = [];
|
||||
for (const workspaceId of ids.slice(0, 8)) {
|
||||
activeAgents.push(
|
||||
await seed.client.createAgent({
|
||||
provider: "mock",
|
||||
model: "e2e-fast-stream",
|
||||
modeId: "load-test",
|
||||
cwd: seed.repoPath,
|
||||
workspaceId,
|
||||
title: "Active overflow agent",
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(
|
||||
activeAgents.map((item) => seed.client.sendAgentMessage(item.id, "active bucket")),
|
||||
);
|
||||
await Promise.all(activeAgents.map((item) => seed.client.waitForFinish(item.id, 15_000)));
|
||||
const attention = page.getByTestId("sidebar-status-group-rows-attention");
|
||||
await expect(attention.locator('[data-testid^="sidebar-workspace-row-"]')).toHaveCount(8);
|
||||
await expect(attention.getByLabel("Show more", { exact: true })).toHaveCount(0);
|
||||
for (const workspaceId of ids.slice(0, 8)) await seed.client.clearWorkspaceAttention(workspaceId);
|
||||
await expect(doneRows).toHaveCount(7);
|
||||
await selectProjectGrouping(page);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
}
|
||||
|
||||
test("compact groups retain active overflow and reveal the current ordered rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const fixture = await seedCompactProject();
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await test.step("Active overflow appears without intervening Done rows", () =>
|
||||
expectActiveOverflowTransitions(page, fixture));
|
||||
await test.step("Reveal follows current sort and survives collapse", () =>
|
||||
expectRevealSurvivesSortingAndCollapse(page, fixture));
|
||||
await test.step("Status reveals Done rows and keeps every active row", () =>
|
||||
expectStatusBucketsRetainActiveOverflow(page, fixture));
|
||||
} finally {
|
||||
await fixture.seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { metroTest as test, expect } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
|
||||
test("published old daemon keeps workspace navigation and gates sorting", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const daemon = await startIsolatedHostDaemon(`srv_sort_old_${randomUUID()}`, {
|
||||
publishedVersion: "0.8.0",
|
||||
});
|
||||
try {
|
||||
const project = await seedWorkspace({ repoPrefix: "sidebar-old-host-", port: daemon.port });
|
||||
try {
|
||||
const second = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
|
||||
title: "Second old workspace",
|
||||
});
|
||||
expect(second.workspace).toBeTruthy();
|
||||
await openPublishedHost(page, daemon, project.workspaceId);
|
||||
await openWorkspace(page, daemon.serverId, second.workspace!.id);
|
||||
await expectSortingRequiresHostUpdate(page);
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await expectStatusHasNoSorting(page);
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openPublishedHost(
|
||||
page: Page,
|
||||
daemon: { serverId: string; port: number },
|
||||
workspaceId: string,
|
||||
) {
|
||||
const host = buildSeededHost({
|
||||
serverId: daemon.serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript((seededHost) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem(
|
||||
"sidebar-view",
|
||||
JSON.stringify({ version: 8, state: { groupMode: "project", workspaceSort: "lastPrompt" } }),
|
||||
);
|
||||
}, host);
|
||||
await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId));
|
||||
}
|
||||
|
||||
async function openWorkspace(page: Page, serverId: string, workspaceId: string) {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||
await expect(row).toBeVisible();
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(new RegExp(workspaceId));
|
||||
}
|
||||
|
||||
async function expectSortingRequiresHostUpdate(page: Page) {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Sort/ }).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async function expectStatusHasNoSorting(page: Page) {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "Update hosts to sort workspaces" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { holdFileUploadResponse } from "../support/helpers/file-upload-gate";
|
||||
import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf";
|
||||
|
||||
@@ -26,12 +30,130 @@ process.stdin.on("data", (chunk) => {
|
||||
setTimeout(finish, 700);
|
||||
`;
|
||||
|
||||
// A TUI stand-in: turns on bracketed paste, reads every quoted path in each
|
||||
// bracketed paste, and echoes typed lines, so the PTY output proves what reached
|
||||
// the terminal and where the file was read from.
|
||||
const FILE_RECEIVER_SCRIPT = String.raw`
|
||||
const fs = require("node:fs");
|
||||
const crypto = require("node:crypto");
|
||||
let buffer = "";
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdout.write("\x1b[?2004hFILE_RECEIVER_READY\r\n");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
for (;;) {
|
||||
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
|
||||
if (!paste) break;
|
||||
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
|
||||
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
|
||||
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
|
||||
process.stdout.write("FILE_READ:" + digest + ":" + location + "\r\n");
|
||||
}
|
||||
buffer = buffer.slice(0, paste.index) + buffer.slice(paste.index + paste[0].length);
|
||||
}
|
||||
if (buffer.includes("\x03")) {
|
||||
process.stdout.write("INTERRUPTED\r\n");
|
||||
buffer = buffer.replaceAll("\x03", "");
|
||||
}
|
||||
const enter = buffer.indexOf("\r");
|
||||
if (enter >= 0 && !buffer.includes("'")) {
|
||||
process.stdout.write("TYPED:" + buffer.slice(0, enter) + "\r\n");
|
||||
buffer = buffer.slice(enter + 1);
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
function pngBytes(seed: number): Buffer {
|
||||
return Buffer.concat([Buffer.from("iVBORw0KGgo=", "base64"), Buffer.from([seed])]);
|
||||
}
|
||||
|
||||
function fileRead(bytes: Buffer, location: "upload" | "local"): string {
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
return `FILE_READ:${digest}:${location}`;
|
||||
}
|
||||
|
||||
// Inactive terminal tabs retain their surfaces, and another page opening a terminal in
|
||||
// the same workspace adds a tab here too. Only the selected tab's surface is visible.
|
||||
function terminalSurface(page: Page) {
|
||||
return page.getByTestId("terminal-surface").filter({ visible: true });
|
||||
}
|
||||
|
||||
function terminalInput(page: Page) {
|
||||
return terminalSurface(page).locator(".xterm-helper-textarea");
|
||||
}
|
||||
|
||||
async function pasteImage(page: Page, bytes: Buffer): Promise<void> {
|
||||
await terminalInput(page).evaluate(
|
||||
(element, data) => {
|
||||
(element as HTMLTextAreaElement).focus();
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.items.add(new File([new Uint8Array(data)], "image.png", { type: "image/png" }));
|
||||
element.dispatchEvent(
|
||||
new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }),
|
||||
);
|
||||
},
|
||||
[...bytes],
|
||||
);
|
||||
}
|
||||
|
||||
interface DroppedFile {
|
||||
name: string;
|
||||
type: string;
|
||||
bytes: Buffer;
|
||||
}
|
||||
|
||||
async function dragTransfer(page: Page, files: DroppedFile[]) {
|
||||
return await page.evaluateHandle(
|
||||
(entries) => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
for (const entry of entries) {
|
||||
dataTransfer.items.add(
|
||||
new File([new Uint8Array(entry.bytes)], entry.name, { type: entry.type }),
|
||||
);
|
||||
}
|
||||
return dataTransfer;
|
||||
},
|
||||
files.map((file) => ({ ...file, bytes: [...file.bytes] })),
|
||||
);
|
||||
}
|
||||
|
||||
async function dragFilesOver(page: Page, files: DroppedFile[]): Promise<void> {
|
||||
const dataTransfer = await dragTransfer(page, files);
|
||||
await terminalInput(page).dispatchEvent("dragenter", { dataTransfer });
|
||||
await terminalInput(page).dispatchEvent("dragover", { dataTransfer });
|
||||
await dataTransfer.dispose();
|
||||
}
|
||||
|
||||
async function dropFiles(page: Page, files: DroppedFile[]): Promise<void> {
|
||||
const dataTransfer = await dragTransfer(page, files);
|
||||
await terminalInput(page).dispatchEvent("drop", { dataTransfer });
|
||||
await dataTransfer.dispose();
|
||||
}
|
||||
|
||||
/** The shared "Drop files here" overlay of the zone around the terminal. */
|
||||
function terminalDropOverlay(page: Page) {
|
||||
return terminalSurface(page)
|
||||
.locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]")
|
||||
.getByTestId("file-drop-backdrop");
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the app treat the e2e daemon as the desktop app's own daemon, so dropped
|
||||
* files resolve to paths inside `directory` instead of uploading.
|
||||
*/
|
||||
async function typeLine(page: Page, text: string): Promise<void> {
|
||||
await terminalInput(page).pressSequentially(text);
|
||||
await terminalInput(page).press("Enter");
|
||||
}
|
||||
|
||||
test.describe("Terminal protocol queries", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" });
|
||||
await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT);
|
||||
await writeFile(path.join(harness.tempRepo.path, "file-receiver.cjs"), FILE_RECEIVER_SCRIPT);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
@@ -58,4 +180,156 @@ test.describe("Terminal protocol queries", () => {
|
||||
await harness.killTerminal(terminalInstance.id);
|
||||
}
|
||||
});
|
||||
|
||||
async function openFileReceiver(page: Page, name: string) {
|
||||
const terminal = await harness.createTerminal({
|
||||
name,
|
||||
command: process.execPath,
|
||||
args: ["file-receiver.cjs"],
|
||||
});
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await expectTerminalOutput(terminal.id, "FILE_RECEIVER_READY");
|
||||
return terminal;
|
||||
}
|
||||
|
||||
// Reads the PTY itself, so output from one pane cannot satisfy another's check.
|
||||
async function terminalOutput(terminalId: string): Promise<string> {
|
||||
const capture = await harness.client.captureTerminal(terminalId);
|
||||
return capture.lines.join("").replace(/\s/g, "");
|
||||
}
|
||||
|
||||
async function expectTerminalOutput(terminalId: string, text: string): Promise<void> {
|
||||
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
|
||||
}
|
||||
|
||||
test("pastes images from two panes into their own terminals as uploaded host files", async ({
|
||||
page,
|
||||
}) => {
|
||||
const secondPage = await page.context().newPage();
|
||||
const first = await openFileReceiver(page, "image-paste-a");
|
||||
const second = await openFileReceiver(secondPage, "image-paste-b");
|
||||
try {
|
||||
await Promise.all([pasteImage(page, pngBytes(1)), pasteImage(secondPage, pngBytes(2))]);
|
||||
|
||||
await expectTerminalOutput(first.id, fileRead(pngBytes(1), "upload"));
|
||||
await expectTerminalOutput(second.id, fileRead(pngBytes(2), "upload"));
|
||||
expect(await terminalOutput(first.id)).not.toContain(fileRead(pngBytes(2), "upload"));
|
||||
expect(await terminalOutput(second.id)).not.toContain(fileRead(pngBytes(1), "upload"));
|
||||
} finally {
|
||||
await secondPage.close();
|
||||
await harness.killTerminal(first.id);
|
||||
await harness.killTerminal(second.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("uploads dropped files of any type when the host is another machine", async ({ page }) => {
|
||||
const terminal = await openFileReceiver(page, "file-drop-remote");
|
||||
const files = [
|
||||
{ name: "shot.png", type: "image/png", bytes: pngBytes(3) },
|
||||
{ name: "notes.txt", type: "text/plain", bytes: Buffer.from("remote notes") },
|
||||
];
|
||||
try {
|
||||
await dragFilesOver(page, files);
|
||||
await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "1");
|
||||
await expect(terminalDropOverlay(page)).toHaveText("Drop files here");
|
||||
|
||||
await dropFiles(page, files);
|
||||
await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "0");
|
||||
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
|
||||
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("pastes dropped files' own paths when the host is this machine", async ({ page }) => {
|
||||
const files = [
|
||||
{ name: "local-shot.png", type: "image/png", bytes: pngBytes(9) },
|
||||
{ name: "local-notes.txt", type: "text/plain", bytes: Buffer.from("local notes") },
|
||||
];
|
||||
for (const file of files) {
|
||||
await writeFile(path.join(harness.tempRepo.path, file.name), file.bytes);
|
||||
}
|
||||
await installLocalDesktopHost(page, harness.tempRepo.path);
|
||||
const terminal = await openFileReceiver(page, "file-drop-local");
|
||||
try {
|
||||
await dropFiles(page, files);
|
||||
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(9), "local"));
|
||||
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("local notes"), "local"));
|
||||
expect(await terminalOutput(terminal.id)).not.toContain(":upload");
|
||||
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("lands image pastes in gesture order while typing stays live", async ({ page }) => {
|
||||
const upload = await holdFileUploadResponse(page);
|
||||
const terminal = await openFileReceiver(page, "image-order");
|
||||
try {
|
||||
await pasteImage(page, pngBytes(4));
|
||||
await upload.waitUntilHeld();
|
||||
await pasteImage(page, pngBytes(5));
|
||||
await expect(page.getByTestId("terminal-file-uploading")).toHaveText("Uploading file…");
|
||||
|
||||
await typeLine(page, "describe");
|
||||
await expectTerminalOutput(terminal.id, "TYPED:describe");
|
||||
|
||||
upload.release();
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(5), "upload"));
|
||||
const output = await terminalOutput(terminal.id);
|
||||
expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeGreaterThanOrEqual(0);
|
||||
expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeLessThan(
|
||||
output.indexOf(fileRead(pngBytes(5), "upload")),
|
||||
);
|
||||
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText("File uploaded");
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("Ctrl+C abandons an image paste that is still uploading", async ({ page }) => {
|
||||
const upload = await holdFileUploadResponse(page);
|
||||
const terminal = await openFileReceiver(page, "image-cancel");
|
||||
try {
|
||||
await pasteImage(page, pngBytes(6));
|
||||
await upload.waitUntilHeld();
|
||||
|
||||
await terminalInput(page).press("Control+c");
|
||||
await expectTerminalOutput(terminal.id, "INTERRUPTED");
|
||||
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
|
||||
upload.release();
|
||||
|
||||
await pasteImage(page, pngBytes(7));
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(7), "upload"));
|
||||
expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(6), "upload"));
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("a disconnect during upload reports one failure and input keeps working", async ({
|
||||
page,
|
||||
}) => {
|
||||
const upload = await holdFileUploadResponse(page);
|
||||
const terminal = await openFileReceiver(page, "image-disconnect");
|
||||
try {
|
||||
await pasteImage(page, pngBytes(8));
|
||||
await upload.waitUntilHeld();
|
||||
|
||||
await upload.disconnect();
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(
|
||||
"Failed to paste file: Host is not connected",
|
||||
);
|
||||
|
||||
await typeLine(page, "after");
|
||||
await expectTerminalOutput(terminal.id, "TYPED:after");
|
||||
expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(8), "upload"));
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { revealSidebarProjectWorkspaces } from "../support/helpers/sidebar";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
switchWorkspaceViaSidebar,
|
||||
@@ -46,6 +47,7 @@ test("previous and next traverse every visible workspace while digit shortcuts s
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1100 });
|
||||
await gotoAppShell(page);
|
||||
await revealSidebarProjectWorkspaces(page, project.projectKey);
|
||||
const order = await readVisibleWorkspaceOrder(page);
|
||||
|
||||
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
|
||||
@@ -68,7 +70,25 @@ test("previous and next traverse every visible workspace while digit shortcuts s
|
||||
workspaceId: order[8],
|
||||
});
|
||||
});
|
||||
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
|
||||
await expectHiddenWorkspaceShortcuts(page, order);
|
||||
});
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) {
|
||||
await page.getByLabel("Show less", { exact: true }).click();
|
||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||
await expect(rows).toHaveCount(7);
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||
await page.keyboard.press("Alt+9");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
|
||||
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press("Alt+]");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
|
||||
await expect(rows).toHaveCount(7);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
|
||||
# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms).
|
||||
# unselected PINNED workspace row. Ordinary workspace rows are not draggable.
|
||||
# Hold past drag arming (180ms), then release.
|
||||
open "${APP_ID}"
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"${WORKSPACE_ROW}\"" 10000
|
||||
|
||||
@@ -35,7 +35,8 @@ export const test = base.extend<{
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
export default function contribute(server) {
|
||||
return server.before("agent.session_open", async ({ request }) => {
|
||||
if (request.reason !== "create") return request;
|
||||
// Metadata helpers have no workspace and must not consume the one-shot failure.
|
||||
if (request.reason !== "create" || !request.workspaceId) return request;
|
||||
const gate = ${JSON.stringify(gate)};
|
||||
let command = await readFile(gate, "utf8").catch(() => "release");
|
||||
while (command === "hold") {
|
||||
|
||||
@@ -18,6 +18,9 @@ export async function openCommandCenterForAgent(
|
||||
workspace: Pick<MockAgentWorkspace, "workspaceId" | "agentId">,
|
||||
): Promise<Locator> {
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Select agent mode \(/ }).filter({ visible: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
const panel = await openCommandCenter(page);
|
||||
return panel.getByTestId("command-center-input");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
workspaceDeckEntryLocator,
|
||||
} from "./workspace-ui";
|
||||
import { getServerId } from "./server-id";
|
||||
import { revealSidebarProjectWorkspaces } from "./sidebar";
|
||||
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
|
||||
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
|
||||
@@ -282,6 +283,7 @@ export async function createCreationScenario(page: Page) {
|
||||
});
|
||||
}
|
||||
await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0);
|
||||
await revealSidebarProjectWorkspaces(page, project.projectKey);
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId });
|
||||
await page
|
||||
.getByTestId(draftTestId!)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function isFileUploadResponse(message: WebSocketMessage): boolean {
|
||||
if (typeof message !== "string") return false;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as { type?: unknown; message?: { type?: unknown } };
|
||||
return (envelope.message ?? envelope).type === "file.upload.response";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the daemon's first `file.upload.response`, so the upload is stored on
|
||||
* the host but the app still sees it in flight.
|
||||
*/
|
||||
export async function holdFileUploadResponse(page: Page) {
|
||||
let held: { browser: WebSocketRoute; message: WebSocketMessage } | null = null;
|
||||
let resolveHeld: (() => void) | null = null;
|
||||
const heldResponse = new Promise<void>((resolve) => {
|
||||
resolveHeld = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (held || !isFileUploadResponse(message)) {
|
||||
browser.send(message);
|
||||
return;
|
||||
}
|
||||
held = { browser, message };
|
||||
resolveHeld?.();
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitUntilHeld: () => heldResponse,
|
||||
release: () => held?.browser.send(held.message),
|
||||
/** Drops the app's connection while the upload is in flight. */
|
||||
disconnect: () => held?.browser.close({ code: 1001, reason: "Upload gate disconnect" }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
// Poses as the desktop app running the e2e daemon as its own managed daemon, so
|
||||
// `useIsLocalDaemon` reports the host as this machine. `directory` is where the
|
||||
// desktop bridge claims dropped files live on disk.
|
||||
export async function installLocalDesktopHost(page: Page, directory: string): Promise<void> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set");
|
||||
}
|
||||
await page.addInitScript(
|
||||
(localHost: { serverId: string; directory: string }) => {
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "linux",
|
||||
invoke: async (command: string) => {
|
||||
if (command === "desktop_daemon_status") {
|
||||
return {
|
||||
serverId: localHost.serverId,
|
||||
status: "running",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "",
|
||||
version: null,
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
webUtils: { getPathForFile: (file: File) => `${localHost.directory}/${file.name}` },
|
||||
};
|
||||
},
|
||||
{ serverId, directory },
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getServerId } from "./server-id";
|
||||
import { projectEquivalenceViewKey } from "./project-view-key";
|
||||
|
||||
interface ContextMenuAnchor {
|
||||
x: number;
|
||||
@@ -295,3 +296,12 @@ export async function expectMobileExplorerHidden(page: Page): Promise<void> {
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function revealSidebarProjectWorkspaces(
|
||||
page: Page,
|
||||
projectKey: string,
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(projectKey)}`)
|
||||
.click();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"start": "npm run start:expo",
|
||||
"start:expo": "cross-env APP_VARIANT=development expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps",
|
||||
"android": "npm run dev:android",
|
||||
"android:development": "npm run dev:android",
|
||||
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
@@ -35,7 +35,6 @@
|
||||
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
|
||||
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs",
|
||||
"dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import esbuild from "esbuild";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const repoRoot = path.resolve(appRoot, "../..");
|
||||
const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts");
|
||||
const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts");
|
||||
|
||||
async function resolveTsPath(basePath) {
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.jsx`,
|
||||
path.join(basePath, "index.ts"),
|
||||
path.join(basePath, "index.tsx"),
|
||||
path.join(basePath, "index.js"),
|
||||
path.join(basePath, "index.jsx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = await fs.stat(candidate);
|
||||
if (stat.isFile()) return candidate;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
const aliasPlugin = {
|
||||
name: "paseo-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@\// }, async (args) => ({
|
||||
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
|
||||
}));
|
||||
build.onResolve({ filter: /^@server\// }, async (args) => ({
|
||||
path: await resolveTsPath(
|
||||
path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)),
|
||||
),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["ios15", "chrome100"],
|
||||
loader: {
|
||||
".css": "text",
|
||||
},
|
||||
plugins: [aliasPlugin],
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
const js = result.outputFiles[0]?.text;
|
||||
if (!js) {
|
||||
throw new Error("terminal webview bundle produced no JavaScript");
|
||||
}
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script>${js}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
|
||||
// Do not edit by hand.
|
||||
|
||||
export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
|
||||
`;
|
||||
|
||||
await fs.writeFile(output, contents);
|
||||
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Largest file the app sends through `client.uploadFile`. */
|
||||
export const FILE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
||||
@@ -34,7 +34,7 @@ export function FileDropBackdrop() {
|
||||
// non-animated children (applying themed Unistyles styles to an Animated.View crashes
|
||||
// on theme change — see docs/unistyles.md).
|
||||
return (
|
||||
<Animated.View style={overlayStyle} pointerEvents="none">
|
||||
<Animated.View style={overlayStyle} pointerEvents="none" testID="file-drop-backdrop">
|
||||
<View style={styles.backdrop} />
|
||||
<View style={styles.content}>
|
||||
<ThemedUpload size={32} uniProps={primaryIconColorMapping} />
|
||||
|
||||
@@ -13,11 +13,11 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
|
||||
|
||||
/**
|
||||
* What a consumer (e.g. a composer) registers to receive files dropped onto the
|
||||
* surrounding FileDropZone. Raster images arrive already persisted via `onFiles`;
|
||||
* everything else arrives raw via `onGenericFiles`.
|
||||
* surrounding FileDropZone. Every file arrives raw via `onGenericFiles`; a sink that
|
||||
* also provides `onFiles` gets its raster images persisted as attachments.
|
||||
*/
|
||||
export interface FileDropSink {
|
||||
onFiles: (images: ImageAttachment[]) => void;
|
||||
onFiles?: (images: ImageAttachment[]) => void;
|
||||
onGenericFiles?: (items: DroppedItem[]) => void;
|
||||
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
|
||||
}
|
||||
|
||||
@@ -148,8 +148,9 @@ export function useDropListeners({
|
||||
sink.onGenericFiles(items);
|
||||
}
|
||||
|
||||
const imagePaths = payload.paths.filter(isRasterImagePath);
|
||||
if (imagePaths.length === 0) {
|
||||
const onFiles = sink.onFiles;
|
||||
const imagePaths = onFiles ? payload.paths.filter(isRasterImagePath) : [];
|
||||
if (!onFiles || imagePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,7 +163,7 @@ export function useDropListeners({
|
||||
// composer the user dropped on (matches the web path below). No post-persist busy
|
||||
// re-check: a mixed drop's own generic upload flips the busy flag, and re-checking
|
||||
// would discard the image from the same drop.
|
||||
sink.onFiles(attachments);
|
||||
onFiles(attachments);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -262,16 +263,17 @@ export function useDropListeners({
|
||||
sink.onGenericFiles(genericItems);
|
||||
}
|
||||
|
||||
const imageFiles = files.filter(isRasterImageFile);
|
||||
const onFiles = sink.onFiles;
|
||||
const imageFiles = onFiles ? files.filter(isRasterImageFile) : [];
|
||||
|
||||
if (imageFiles.length === 0) return;
|
||||
if (!onFiles || imageFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const attachments = await Promise.all(imageFiles.map(fileToImageAttachment));
|
||||
// No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag,
|
||||
// and re-checking would discard the image from the same drop. The guard at drop start
|
||||
// already rejects drops that begin while busy.
|
||||
sink.onFiles(attachments);
|
||||
onFiles(attachments);
|
||||
} catch (error) {
|
||||
console.error("[useDropListeners] Failed to process dropped files:", error);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "@testing-library/react";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -83,6 +84,8 @@ function workspace(input: {
|
||||
scripts?: WorkspaceDescriptor["scripts"];
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName,
|
||||
@@ -171,10 +174,6 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: ["project-a", "project-b"],
|
||||
workspaceOrderByProject: {
|
||||
["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`],
|
||||
["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -348,7 +347,6 @@ describe("sidebar workspace render isolation", () => {
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -439,3 +437,190 @@ describe("sidebar workspace render isolation", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar group visibility", () => {
|
||||
const entries = (statuses: WorkspaceDescriptor["status"][]) =>
|
||||
statuses.map((status, index) =>
|
||||
createSidebarWorkspaceEntry({
|
||||
serverId: SERVER_ID,
|
||||
workspace: workspace({
|
||||
id: `row-${index + 1}`,
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: `Row ${index + 1}`,
|
||||
status,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const policy = {
|
||||
kind: "workspaces" as const,
|
||||
getStatusBucket: (entry: ReturnType<typeof createSidebarWorkspaceEntry>) => entry.statusBucket,
|
||||
};
|
||||
const names = (items: readonly ReturnType<typeof createSidebarWorkspaceEntry>[]) =>
|
||||
items.map((item) => item.workspaceId);
|
||||
|
||||
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
|
||||
const items = entries(Array(count).fill("done"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.current.canToggle).toBe(count > 7);
|
||||
});
|
||||
|
||||
it.each(["needs_input", "failed", "attention", "running"] as const)(
|
||||
"keeps row twelve visible for %s without filling gaps",
|
||||
(status) => {
|
||||
const items = entries([...Array(11).fill("done"), status]);
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-1",
|
||||
"row-2",
|
||||
"row-3",
|
||||
"row-4",
|
||||
"row-5",
|
||||
"row-6",
|
||||
"row-7",
|
||||
"row-12",
|
||||
]);
|
||||
expect(result.current.visibleItems[7]).toBe(items[11]);
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("shows eight active rows once with no redundant toggle", () => {
|
||||
const items = entries([
|
||||
"running",
|
||||
"needs_input",
|
||||
"attention",
|
||||
"failed",
|
||||
"running",
|
||||
"attention",
|
||||
"needs_input",
|
||||
"failed",
|
||||
]);
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
});
|
||||
|
||||
it("recomputes current ordered membership and retains reveal through status, order and size changes", () => {
|
||||
const items = entries([
|
||||
...Array(7).fill("done"),
|
||||
"running",
|
||||
"done",
|
||||
"failed",
|
||||
"done",
|
||||
"attention",
|
||||
]);
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ rows }) => useLimitedSidebarGroup(rows, policy),
|
||||
{ initialProps: { rows: items } },
|
||||
);
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-1",
|
||||
"row-2",
|
||||
"row-3",
|
||||
"row-4",
|
||||
"row-5",
|
||||
"row-6",
|
||||
"row-7",
|
||||
"row-8",
|
||||
"row-10",
|
||||
"row-12",
|
||||
]);
|
||||
act(() => result.current.toggleExpanded());
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) });
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
expect(result.current.expanded).toBe(true);
|
||||
rerender({ rows: items.slice(0, 3) });
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
rerender({ rows: items.toReversed() });
|
||||
expect(result.current.visibleItems).toEqual(items.toReversed());
|
||||
act(() => result.current.toggleExpanded());
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-12",
|
||||
"row-11",
|
||||
"row-10",
|
||||
"row-9",
|
||||
"row-8",
|
||||
"row-7",
|
||||
"row-6",
|
||||
]);
|
||||
const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" };
|
||||
rerender({ rows: [newest, ...items] });
|
||||
expect(result.current.visibleItems[0]).toBe(newest);
|
||||
expect(result.current.visibleItems).toHaveLength(10);
|
||||
expect(result.current.expanded).toBe(false);
|
||||
act(() => result.current.toggleExpanded());
|
||||
unmount();
|
||||
const remounted = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
expect(remounted.result.current.expanded).toBe(false);
|
||||
expect(remounted.result.current.visibleItems).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("follows effective pending creation, recovery and agent activity without changing Created order", () => {
|
||||
const items = entries(Array(12).fill("done"));
|
||||
const descriptor = workspace({
|
||||
id: "row-12",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: "Row 12",
|
||||
});
|
||||
const pending = {
|
||||
draftId: "draft",
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: descriptor.id,
|
||||
agentId: null,
|
||||
clientMessageId: "message",
|
||||
text: "Create",
|
||||
timestamp: 1234,
|
||||
lifecycle: "active" as const,
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy),
|
||||
{ initialProps: { last: items[11] } },
|
||||
);
|
||||
expect(result.current.visibleItems).toHaveLength(7);
|
||||
const creating = createSidebarWorkspaceEntry({
|
||||
serverId: SERVER_ID,
|
||||
workspace: descriptor,
|
||||
pendingCreateAttempts: { draft: pending },
|
||||
});
|
||||
expect(creating.statusBucket).toBe("running");
|
||||
rerender({ last: creating });
|
||||
expect(names(result.current.visibleItems)).toEqual([...names(items.slice(0, 7)), "row-12"]);
|
||||
const abandoned = createSidebarWorkspaceEntry({
|
||||
serverId: SERVER_ID,
|
||||
workspace: descriptor,
|
||||
pendingCreateAttempts: { draft: { ...pending, lifecycle: "abandoned" } },
|
||||
});
|
||||
rerender({ last: abandoned });
|
||||
expect(result.current.visibleItems).toHaveLength(7);
|
||||
rerender({ last: creating });
|
||||
for (const status of ["running", "attention", "needs_input", "failed", "done"] as const) {
|
||||
const last = createSidebarWorkspaceEntry({
|
||||
serverId: SERVER_ID,
|
||||
workspace: descriptor,
|
||||
workspaceAgentActivity: new Map([
|
||||
[descriptor.id, { agentId: "agent", status, enteredAt: null }],
|
||||
]),
|
||||
});
|
||||
expect(last.statusBucket).toBe(status);
|
||||
rerender({ last });
|
||||
expect(names(result.current.visibleItems)).toEqual(
|
||||
status === "done" ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-12"],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the twenty-row pinned policy regardless of status", () => {
|
||||
const items = entries(Array(21).fill("running"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items));
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
act(() => result.current.toggleExpanded());
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
act(() => result.current.toggleExpanded());
|
||||
expect(result.current.visibleItems).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,7 +272,7 @@ interface WorkspaceRowInnerProps {
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
drag: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
isArchiving: boolean;
|
||||
isCreating?: boolean;
|
||||
@@ -381,8 +381,6 @@ function getProjectWorkspaceRowStyle({
|
||||
];
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
|
||||
const prBadgeStyles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
@@ -1236,7 +1234,7 @@ function WorkspaceRowWithMenu({
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
drag: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
canCopyBranchName: boolean;
|
||||
@@ -1436,7 +1434,7 @@ function WorkspaceRowItem({
|
||||
enabled: selectionEnabled,
|
||||
})}
|
||||
onPress={handlePress}
|
||||
drag={drag ?? noop}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
@@ -1465,7 +1463,7 @@ function areWorkspaceRowItemPropsEqual(
|
||||
previous.hostBadge === next.hostBadge &&
|
||||
previous.leadingProjectName === next.leadingProjectName &&
|
||||
previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri &&
|
||||
previous.shortcutNumber === next.shortcutNumber &&
|
||||
(!next.showShortcutBadge || previous.shortcutNumber === next.shortcutNumber) &&
|
||||
previous.showShortcutBadge === next.showShortcutBadge &&
|
||||
previous.canCopyBranchName === next.canCopyBranchName &&
|
||||
previous.canPin === next.canPin &&
|
||||
@@ -1507,7 +1505,7 @@ function WorkspaceRow({
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
drag: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
canCopyBranchName: boolean;
|
||||
@@ -1552,16 +1550,12 @@ function ProjectBlock({
|
||||
selectionEnabled,
|
||||
showShortcutBadges,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
parentGestureRef,
|
||||
onToggleCollapsed,
|
||||
onWorkspacePress,
|
||||
onWorkspaceReorder,
|
||||
onWorktreeCreated,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
useNestable,
|
||||
dragGestureHostActive,
|
||||
creatingWorkspaceIds,
|
||||
activeWorkspaceSelection,
|
||||
hostBadgeByServerId,
|
||||
@@ -1577,16 +1571,12 @@ function ProjectBlock({
|
||||
selectionEnabled: boolean;
|
||||
showShortcutBadges: boolean;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
onToggleCollapsed: (projectViewKey: string) => void;
|
||||
onWorkspacePress?: () => void;
|
||||
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
onWorktreeCreated?: (workspaceId: string) => void;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
useNestable: boolean;
|
||||
dragGestureHostActive?: boolean;
|
||||
creatingWorkspaceIds: ReadonlySet<string>;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
@@ -1594,12 +1584,20 @@ function ProjectBlock({
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const visibilityPolicy = useMemo(
|
||||
() => ({
|
||||
kind: "workspaces" as const,
|
||||
getStatusBucket: (workspace: SidebarWorkspacePlacement) =>
|
||||
workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket,
|
||||
}),
|
||||
[workspaceEntriesByKey],
|
||||
);
|
||||
const {
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(project.workspaces);
|
||||
} = useLimitedSidebarGroup(project.workspaces, visibilityPolicy);
|
||||
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
|
||||
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
|
||||
@@ -1628,16 +1626,10 @@ function ProjectBlock({
|
||||
});
|
||||
|
||||
const renderWorkspaceRow = useCallback(
|
||||
(
|
||||
item: SidebarWorkspacePlacement,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
) => {
|
||||
(item: SidebarWorkspacePlacement) => {
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
key={item.workspaceKey}
|
||||
workspace={item}
|
||||
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
|
||||
hostBadge={hostBadgeByServerId.get(item.serverId) ?? null}
|
||||
@@ -1650,9 +1642,6 @@ function ProjectBlock({
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -1671,29 +1660,6 @@ function ProjectBlock({
|
||||
],
|
||||
);
|
||||
|
||||
const renderWorkspace = useCallback(
|
||||
({
|
||||
item,
|
||||
drag: workspaceDrag,
|
||||
isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return renderWorkspaceRow(item, {
|
||||
drag: workspaceDrag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
});
|
||||
},
|
||||
[renderWorkspaceRow],
|
||||
);
|
||||
|
||||
const handleWorkspaceDragEnd = useCallback(
|
||||
(workspaces: SidebarWorkspacePlacement[]) => {
|
||||
onWorkspaceReorder(project.viewKey, workspaces);
|
||||
},
|
||||
[onWorkspaceReorder, project.viewKey],
|
||||
);
|
||||
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
||||
@@ -1759,20 +1725,12 @@ function ProjectBlock({
|
||||
if (project.workspaces.length > 0) {
|
||||
projectChildren = (
|
||||
<>
|
||||
<DraggableList
|
||||
<View
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
data={visibleWorkspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
style={styles.workspaceListContainer}
|
||||
>
|
||||
{visibleWorkspaces.map(renderWorkspaceRow)}
|
||||
</View>
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
@@ -1834,33 +1792,54 @@ type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
||||
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
||||
return (
|
||||
previous.project === next.project &&
|
||||
previous.workspaceEntriesByKey === next.workspaceEntriesByKey &&
|
||||
projectWorkspaceEntriesEqual(previous, next) &&
|
||||
previous.collapsed === next.collapsed &&
|
||||
previous.displayName === next.displayName &&
|
||||
previous.iconDataUri === next.iconDataUri &&
|
||||
previous.selectionEnabled === next.selectionEnabled &&
|
||||
previous.showShortcutBadges === next.showShortcutBadges &&
|
||||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
||||
projectShortcutBadgesEqual(previous, next) &&
|
||||
previous.hostBadgeByServerId === next.hostBadgeByServerId &&
|
||||
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
|
||||
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
|
||||
previous.onWorktreeCreated === next.onWorktreeCreated &&
|
||||
previous.drag === next.drag &&
|
||||
previous.isDragging === next.isDragging &&
|
||||
previous.dragHandleProps === next.dragHandleProps &&
|
||||
previous.useNestable === next.useNestable &&
|
||||
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
||||
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
||||
previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId &&
|
||||
areProjectBlockSelectionsEqual(previous, next)
|
||||
);
|
||||
}
|
||||
|
||||
function projectShortcutBadgesEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
||||
if (
|
||||
!next.showShortcutBadges ||
|
||||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey
|
||||
)
|
||||
return true;
|
||||
return next.project.workspaces.every(
|
||||
({ workspaceKey }) =>
|
||||
previous.shortcutIndexByWorkspaceKey.get(workspaceKey) ===
|
||||
next.shortcutIndexByWorkspaceKey.get(workspaceKey),
|
||||
);
|
||||
}
|
||||
|
||||
function projectWorkspaceEntriesEqual(
|
||||
previous: ProjectBlockProps,
|
||||
next: ProjectBlockProps,
|
||||
): boolean {
|
||||
if (previous.workspaceEntriesByKey === next.workspaceEntriesByKey) return true;
|
||||
return next.project.workspaces.every(
|
||||
({ workspaceKey }) =>
|
||||
previous.workspaceEntriesByKey.get(workspaceKey) ===
|
||||
next.workspaceEntriesByKey.get(workspaceKey),
|
||||
);
|
||||
}
|
||||
|
||||
function areProjectBlockSelectionsEqual(
|
||||
previous: ProjectBlockProps,
|
||||
next: ProjectBlockProps,
|
||||
@@ -2137,8 +2116,6 @@ function ProjectModeList({
|
||||
|
||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
||||
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
|
||||
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
|
||||
|
||||
const isWorkspaceRoute = useMemo(
|
||||
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
|
||||
@@ -2236,30 +2213,6 @@ function ProjectModeList({
|
||||
[getProjectOrder, setProjectOrder],
|
||||
);
|
||||
|
||||
const handleWorkspaceReorder = useCallback(
|
||||
(projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
|
||||
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
|
||||
const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey);
|
||||
if (
|
||||
!hasVisibleOrderChanged({
|
||||
currentOrder: currentWorkspaceOrder,
|
||||
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkspaceOrder(
|
||||
projectViewKey,
|
||||
mergeWithRemainder({
|
||||
currentOrder: currentWorkspaceOrder,
|
||||
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[getWorkspaceOrder, setWorkspaceOrder],
|
||||
);
|
||||
|
||||
const handleWorktreeCreated = useCallback((workspaceId: string) => {
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -2306,16 +2259,12 @@ function ProjectModeList({
|
||||
selectionEnabled={selectionEnabled}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
parentGestureRef={parentGestureRef}
|
||||
onToggleCollapsed={onToggleProjectCollapsed}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorkspaceReorder={handleWorkspaceReorder}
|
||||
onWorktreeCreated={handleWorktreeCreated}
|
||||
drag={dragState.drag}
|
||||
isDragging={dragState.isDragging}
|
||||
dragHandleProps={dragState.dragHandleProps}
|
||||
useNestable={platformIsNative}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
@@ -2329,15 +2278,12 @@ function ProjectModeList({
|
||||
collapsedProjectKeys,
|
||||
activeWorkspaceSelection,
|
||||
handleWorktreeCreated,
|
||||
handleWorkspaceReorder,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
onWorkspacePress,
|
||||
onToggleProjectCollapsed,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
projectIconByProjectViewKey,
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
|
||||
@@ -167,7 +167,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
const hosts = useHosts();
|
||||
// `allProjects`, never `projects`: the model's `projects` is already filtered, so a picker fed
|
||||
// from it would lose the row that undoes the filter as soon as the filter narrowed to one.
|
||||
const { allProjects, resolvedProjectFilters } = useSidebarModel();
|
||||
const { allProjects, resolvedProjectFilters, workspaceSortSupported } = useSidebarModel();
|
||||
const { labels } = useWorkspaceLabelProjection();
|
||||
const [managerOpen, setManagerOpen] = useState(false);
|
||||
const openManager = useCallback(() => setManagerOpen(true), []);
|
||||
@@ -240,6 +240,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
},
|
||||
];
|
||||
|
||||
if (preferences.grouping === "project") {
|
||||
definitions.push({
|
||||
id: "sort",
|
||||
title: t("sidebar.display.sort.label"),
|
||||
content: workspaceSortSupported ? (
|
||||
<SortPage preferences={preferences} />
|
||||
) : (
|
||||
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (showHostFilter) {
|
||||
definitions.push({
|
||||
id: "hostFilter",
|
||||
@@ -273,6 +285,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
}, [
|
||||
t,
|
||||
preferences,
|
||||
workspaceSortSupported,
|
||||
hosts,
|
||||
showHostFilter,
|
||||
showProjectFilter,
|
||||
@@ -308,6 +321,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
>
|
||||
{t("sidebar.display.grouping.label")}
|
||||
</MenuSubTrigger>
|
||||
{preferences.grouping === "project" ? (
|
||||
<MenuSubTrigger
|
||||
id="sort"
|
||||
value={
|
||||
workspaceSortSupported
|
||||
? t(`sidebar.display.sort.${preferences.workspaceSort}`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("sidebar.display.sort.label")}
|
||||
</MenuSubTrigger>
|
||||
) : null}
|
||||
<MenuSubTrigger
|
||||
id="titleSource"
|
||||
value={t(TITLE_SOURCE_LABEL_KEYS[preferences.titleSource])}
|
||||
@@ -374,6 +399,23 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
function SortPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { setWorkspaceSort } = preferences;
|
||||
const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]);
|
||||
const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]);
|
||||
return (
|
||||
<>
|
||||
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
|
||||
{t("sidebar.display.sort.created")}
|
||||
</MenuItem>
|
||||
<MenuItem selected={preferences.workspaceSort === "lastPrompt"} onSelect={selectLastPrompt}>
|
||||
{t("sidebar.display.sort.lastPrompt")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every label you could filter by, wherever it lives, one row each.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WorkspaceSort } from "@/projects/workspace-structure";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
useAppSettings,
|
||||
@@ -19,6 +20,8 @@ export interface SidebarDisplayPreferences {
|
||||
showBackground: boolean;
|
||||
toggleBackground: () => void;
|
||||
grouping: SidebarGroupMode;
|
||||
workspaceSort: WorkspaceSort;
|
||||
setWorkspaceSort: (sort: WorkspaceSort) => void;
|
||||
setGrouping: (mode: SidebarGroupMode) => void;
|
||||
titleSource: WorkspaceTitleSource;
|
||||
setTitleSource: (source: WorkspaceTitleSource) => void;
|
||||
@@ -52,6 +55,8 @@ export interface SidebarDisplayPreferences {
|
||||
export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
const showBackground = useSidebarViewStore((state) => state.showBackground);
|
||||
const toggleBackground = useSidebarViewStore((state) => state.toggleBackground);
|
||||
const workspaceSort = useSidebarViewStore((state) => state.workspaceSort);
|
||||
const setWorkspaceSort = useSidebarViewStore((state) => state.setWorkspaceSort);
|
||||
const grouping = useSidebarViewStore((state) => state.groupMode);
|
||||
const setGrouping = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
@@ -111,6 +116,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
showBackground,
|
||||
toggleBackground,
|
||||
grouping,
|
||||
workspaceSort,
|
||||
setWorkspaceSort,
|
||||
setGrouping,
|
||||
titleSource: workspaceTitleSource,
|
||||
setTitleSource,
|
||||
@@ -134,6 +141,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
showBackground,
|
||||
toggleBackground,
|
||||
grouping,
|
||||
workspaceSort,
|
||||
setWorkspaceSort,
|
||||
setGrouping,
|
||||
workspaceTitleSource,
|
||||
setTitleSource,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
|
||||
|
||||
export type SidebarGroupVisibilityPolicy<T> =
|
||||
| { kind: "pinned" }
|
||||
| { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket };
|
||||
|
||||
export function selectSidebarGroupVisibility<T>(
|
||||
items: readonly T[],
|
||||
expanded: boolean,
|
||||
policy: SidebarGroupVisibilityPolicy<T>,
|
||||
) {
|
||||
const limit = policy.kind === "pinned" ? 20 : 7;
|
||||
const baseline = items.filter(
|
||||
(item, index) =>
|
||||
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"),
|
||||
);
|
||||
return {
|
||||
visibleItems: expanded ? items.slice() : baseline,
|
||||
canToggle: baseline.length < items.length,
|
||||
};
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export function SidebarModelProvider({
|
||||
visibleWorkspaceKeys.has(workspace.workspaceKey),
|
||||
);
|
||||
return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0)
|
||||
? [{ ...project, workspaces }]
|
||||
? [workspaces.length === project.workspaces.length ? project : { ...project, workspaces }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -319,6 +319,11 @@ function StatusGroupList({
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_GROUP_VISIBILITY = {
|
||||
kind: "workspaces" as const,
|
||||
getStatusBucket: (workspace: SidebarWorkspaceEntry) => workspace.statusBucket,
|
||||
};
|
||||
|
||||
function StatusGroupRows({
|
||||
group,
|
||||
collapsed,
|
||||
@@ -345,7 +350,7 @@ function StatusGroupRows({
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(group.rows);
|
||||
} = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY);
|
||||
|
||||
return (
|
||||
<View style={collapsed ? undefined : styles.statusGroupBlockExpanded}>
|
||||
|
||||
@@ -41,8 +41,6 @@ import {
|
||||
type SidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
|
||||
function noop() {}
|
||||
|
||||
interface SidebarWorkspaceRowProps {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
@@ -239,7 +237,7 @@ function WorkspaceRowBody({
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const draggable = Boolean(drag);
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag: drag ?? noop,
|
||||
drag,
|
||||
menuController: null,
|
||||
});
|
||||
const {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
selectSidebarGroupVisibility,
|
||||
type SidebarGroupVisibilityPolicy,
|
||||
} from "./sidebar-group-visibility";
|
||||
|
||||
const INITIAL_VISIBLE_ITEMS = 20;
|
||||
const PINNED_POLICY = { kind: "pinned" } as const;
|
||||
|
||||
export function useLimitedSidebarGroup<T>(items: readonly T[]) {
|
||||
export function useLimitedSidebarGroup<T>(
|
||||
items: readonly T[],
|
||||
policy: SidebarGroupVisibilityPolicy<T> = PINNED_POLICY,
|
||||
) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibleItems = useMemo(
|
||||
() => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)),
|
||||
[expanded, items],
|
||||
const visibility = useMemo(
|
||||
() => selectSidebarGroupVisibility(items, expanded, policy),
|
||||
[expanded, items, policy],
|
||||
);
|
||||
const canToggle = items.length > INITIAL_VISIBLE_ITEMS;
|
||||
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
||||
|
||||
return { visibleItems, expanded, canToggle, toggleExpanded };
|
||||
return { ...visibility, expanded, toggleExpanded };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
import type { useContextMenu } from "@/components/ui/context-menu";
|
||||
|
||||
export function useLongPressDragInteraction(input: {
|
||||
drag: () => void;
|
||||
drag?: () => void;
|
||||
menuController: ReturnType<typeof useContextMenu> | null;
|
||||
}) {
|
||||
const { drag, menuController } = input;
|
||||
@@ -59,7 +59,7 @@ export function useLongPressDragInteraction(input: {
|
||||
|
||||
const armTimers = useCallback(() => {
|
||||
clearTimers();
|
||||
if (platformIsWeb) {
|
||||
if (platformIsWeb || !drag) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,18 @@ import { Pressable, Text, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
export interface TerminalPasteActionProps {
|
||||
hasClipboardText: boolean;
|
||||
/** Clipboard holds text or an image, so a paste attempt can do something. */
|
||||
hasClipboardContent: boolean;
|
||||
onPaste: () => void;
|
||||
}
|
||||
|
||||
export function TerminalPasteAction({ hasClipboardText, onPaste }: TerminalPasteActionProps) {
|
||||
export function TerminalPasteAction({ hasClipboardContent, onPaste }: TerminalPasteActionProps) {
|
||||
return (
|
||||
<TerminalActionButton
|
||||
label="Paste"
|
||||
accessibilityLabel="Paste"
|
||||
testID="terminal-paste"
|
||||
disabled={!hasClipboardText}
|
||||
disabled={!hasClipboardContent}
|
||||
onPress={onPaste}
|
||||
variant="key"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
TerminalLocalFileLinkTarget,
|
||||
} from "../terminal/local-links/terminal-local-link-provider";
|
||||
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
|
||||
import type { TerminalFile } from "../terminal/file-paste";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
|
||||
@@ -75,4 +76,5 @@ export interface TerminalEmulatorProps {
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
resizeRequestToken?: number;
|
||||
onFilePaste?: (file: Promise<TerminalFile | null>) => void;
|
||||
}
|
||||
|
||||
@@ -1,715 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
} from "react";
|
||||
import {
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { WebView, type WebViewMessageEvent } from "react-native-webview";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import type {
|
||||
TerminalOutputData,
|
||||
TerminalFindResult,
|
||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type {
|
||||
TerminalLocalFileLinkSource,
|
||||
TerminalLocalFileLinkTarget,
|
||||
} from "../terminal/local-links/terminal-local-link-provider";
|
||||
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { openExternalUrl } from "../utils/open-external-url";
|
||||
import type { TerminalEmulatorHandle, TerminalEmulatorProps } from "./terminal-emulator-contract";
|
||||
import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing";
|
||||
import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness";
|
||||
|
||||
type BridgeInboundMessage =
|
||||
| {
|
||||
type: "mount";
|
||||
streamKey: string;
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}
|
||||
| { type: "unmount"; streamKey: string }
|
||||
| { type: "writeOutput"; streamKey: string; text: string }
|
||||
| { type: "restoreOutput"; streamKey: string; text: string }
|
||||
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
|
||||
| { type: "paste"; streamKey: string; text: string }
|
||||
| { type: "clear"; streamKey: string }
|
||||
| { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" }
|
||||
| { type: "clearFind"; streamKey: string }
|
||||
| { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } }
|
||||
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
|
||||
| { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean }
|
||||
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
||||
| { type: "setScrollback"; streamKey: string; lines: number }
|
||||
| { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number }
|
||||
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
|
||||
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
|
||||
| {
|
||||
type: "resolveLocalFileLinkResponse";
|
||||
streamKey: string;
|
||||
requestId: number;
|
||||
target: TerminalLocalFileLinkTarget | null;
|
||||
};
|
||||
|
||||
type BridgeOutboundMessage =
|
||||
| { type: "bridgeReady" }
|
||||
| { type: "rendererReady"; streamKey: string; isReady: boolean }
|
||||
| { type: "input"; streamKey: string; data: string }
|
||||
| { type: "findRequest"; streamKey: string }
|
||||
| { type: "findResult"; streamKey: string; result: TerminalFindResult }
|
||||
| {
|
||||
type: "resize";
|
||||
streamKey: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
shouldClaim?: boolean;
|
||||
forceClaim?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "terminalKey";
|
||||
streamKey: string;
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}
|
||||
| { type: "pendingModifiersConsumed"; streamKey: string }
|
||||
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
|
||||
| { type: "openExternalUrl"; streamKey: string; url: string }
|
||||
| {
|
||||
type: "resolveLocalFileLink";
|
||||
streamKey: string;
|
||||
requestId: number;
|
||||
source: TerminalLocalFileLinkSource;
|
||||
}
|
||||
| {
|
||||
type: "openLocalFileLink";
|
||||
streamKey: string;
|
||||
target: TerminalLocalFileLinkTarget;
|
||||
disposition: "main" | "side";
|
||||
}
|
||||
| { type: "swipeLeft"; streamKey: string }
|
||||
| { type: "swipeRight"; streamKey: string }
|
||||
| { type: "debug"; message: string; details?: unknown };
|
||||
|
||||
const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml };
|
||||
const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"];
|
||||
const BRIDGE_READY_TIMEOUT_MS = 2_500;
|
||||
const RENDERER_READY_TIMEOUT_MS = 2_500;
|
||||
const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8;
|
||||
type WebViewProps = ComponentProps<typeof WebView>;
|
||||
interface PendingTerminalTap {
|
||||
startX: number;
|
||||
startY: number;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
function buildThemeKey(theme: ITheme): string {
|
||||
return JSON.stringify(theme);
|
||||
}
|
||||
|
||||
function serializeForInjectedJavaScript(message: BridgeInboundMessage): string {
|
||||
return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
|
||||
}
|
||||
|
||||
function createMountMessage(input: {
|
||||
streamKey: string;
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}): BridgeInboundMessage {
|
||||
return {
|
||||
type: "mount",
|
||||
streamKey: input.streamKey,
|
||||
initialSnapshot: input.initialSnapshot,
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
theme: input.theme,
|
||||
fontFamily: input.fontFamily,
|
||||
fontSize: input.fontSize,
|
||||
pendingModifiers: input.pendingModifiers,
|
||||
swipeGesturesEnabled: input.swipeGesturesEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export default function WebViewTerminalEmulator({
|
||||
ref,
|
||||
streamKey,
|
||||
testId = "terminal-surface",
|
||||
xtermTheme = {
|
||||
background: "#0b0b0b",
|
||||
foreground: "#e6e6e6",
|
||||
cursor: "#e6e6e6",
|
||||
},
|
||||
scrollbackLines,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
swipeGesturesEnabled = false,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
initialSnapshot = null,
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onRendererReadyChange,
|
||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||
focusRequestToken = 0,
|
||||
resizeRequestToken = 0,
|
||||
}: TerminalEmulatorProps) {
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const [webViewEpoch, setWebViewEpoch] = useState(0);
|
||||
const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0);
|
||||
const bridgeReadyRef = useRef(false);
|
||||
const bridgeReadyVersionRef = useRef(0);
|
||||
const rendererReadyVersionRef = useRef(0);
|
||||
const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]);
|
||||
const outputDecoderRef = useRef(new TextDecoder());
|
||||
const mountRequestedStreamKeyRef = useRef<string | null>(null);
|
||||
const rendererReadyStreamKeyRef = useRef<string | null>(null);
|
||||
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingTapRef = useRef<PendingTerminalTap | null>(null);
|
||||
const mountConfigRef = useRef({
|
||||
streamKey,
|
||||
initialSnapshot,
|
||||
scrollbackLines,
|
||||
theme: xtermTheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
pendingModifiers,
|
||||
swipeGesturesEnabled,
|
||||
});
|
||||
mountConfigRef.current = {
|
||||
streamKey,
|
||||
initialSnapshot,
|
||||
scrollbackLines,
|
||||
theme: xtermTheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
pendingModifiers,
|
||||
swipeGesturesEnabled,
|
||||
};
|
||||
const callbacksRef = useRef({
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
onRendererReadyChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
});
|
||||
callbacksRef.current = {
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
onRendererReadyChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
};
|
||||
|
||||
const clearBridgeReadyTimeout = useCallback(() => {
|
||||
if (bridgeReadyTimeoutRef.current === null) return;
|
||||
clearTimeout(bridgeReadyTimeoutRef.current);
|
||||
bridgeReadyTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearRendererReadyTimeout = useCallback(() => {
|
||||
if (rendererReadyTimeoutRef.current === null) return;
|
||||
clearTimeout(rendererReadyTimeoutRef.current);
|
||||
rendererReadyTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const resetWebViewDocument = useCallback(() => {
|
||||
clearBridgeReadyTimeout();
|
||||
clearRendererReadyTimeout();
|
||||
bridgeReadyRef.current = false;
|
||||
pendingMessagesRef.current = [];
|
||||
mountRequestedStreamKeyRef.current = null;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
|
||||
setWebViewEpoch((value) => value + 1);
|
||||
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]);
|
||||
|
||||
const scheduleBridgeReadyWatchdog = useCallback(() => {
|
||||
clearBridgeReadyTimeout();
|
||||
const expectedBridgeReadyVersion = bridgeReadyVersionRef.current;
|
||||
bridgeReadyTimeoutRef.current = setTimeout(() => {
|
||||
bridgeReadyTimeoutRef.current = null;
|
||||
if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) {
|
||||
return;
|
||||
}
|
||||
resetWebViewDocument();
|
||||
}, BRIDGE_READY_TIMEOUT_MS);
|
||||
}, [clearBridgeReadyTimeout, resetWebViewDocument]);
|
||||
|
||||
const scheduleRendererReadyWatchdog = useCallback(() => {
|
||||
clearRendererReadyTimeout();
|
||||
const expectedRendererReadyVersion = rendererReadyVersionRef.current;
|
||||
rendererReadyTimeoutRef.current = setTimeout(() => {
|
||||
rendererReadyTimeoutRef.current = null;
|
||||
if (
|
||||
!shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: expectedRendererReadyVersion,
|
||||
currentReadyVersion: rendererReadyVersionRef.current,
|
||||
expectedStreamKey: streamKey,
|
||||
mountRequestedStreamKey: mountRequestedStreamKeyRef.current,
|
||||
rendererReadyStreamKey: rendererReadyStreamKeyRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
resetWebViewDocument();
|
||||
}, RENDERER_READY_TIMEOUT_MS);
|
||||
}, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]);
|
||||
|
||||
const flushPendingMessages = useCallback(() => {
|
||||
if (!bridgeReadyRef.current || !webViewRef.current) return;
|
||||
const pending = pendingMessagesRef.current.splice(0);
|
||||
for (const message of pending) {
|
||||
const payload = serializeForInjectedJavaScript(message);
|
||||
webViewRef.current.injectJavaScript(
|
||||
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendToWebView = useCallback((message: BridgeInboundMessage) => {
|
||||
if (!bridgeReadyRef.current || !webViewRef.current) {
|
||||
pendingMessagesRef.current.push(message);
|
||||
return;
|
||||
}
|
||||
const payload = serializeForInjectedJavaScript(message);
|
||||
webViewRef.current.injectJavaScript(
|
||||
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): TerminalEmulatorHandle => ({
|
||||
find: {
|
||||
setWidgetSize: (size) => sendToWebView({ type: "findWidgetSize", streamKey, size }),
|
||||
search: (query, direction) => sendToWebView({ type: "find", streamKey, query, direction }),
|
||||
clear: () => sendToWebView({ type: "clearFind", streamKey }),
|
||||
},
|
||||
writeOutput: (data: TerminalOutputData) => {
|
||||
const output = outputDecoderRef.current.decode(data, { stream: true });
|
||||
if (output.length === 0) {
|
||||
return;
|
||||
}
|
||||
sendToWebView({ type: "writeOutput", streamKey, text: output });
|
||||
},
|
||||
restoreOutput: (data: TerminalOutputData) => {
|
||||
outputDecoderRef.current.decode();
|
||||
const text = outputDecoderRef.current.decode(data, { stream: false });
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
sendToWebView({ type: "restoreOutput", streamKey, text });
|
||||
},
|
||||
renderSnapshot: (state: TerminalState | null) => {
|
||||
outputDecoderRef.current.decode();
|
||||
sendToWebView({ type: "renderSnapshot", streamKey, state });
|
||||
},
|
||||
paste: (text: string) => {
|
||||
sendToWebView({ type: "paste", streamKey, text });
|
||||
},
|
||||
copySelection: async () => "",
|
||||
clear: () => {
|
||||
outputDecoderRef.current.decode();
|
||||
sendToWebView({ type: "clear", streamKey });
|
||||
},
|
||||
claimSize: () => {
|
||||
sendToWebView({ type: "resize", streamKey, forceClaim: true });
|
||||
},
|
||||
showKeyboard: () => {
|
||||
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
|
||||
webViewRef.current?.requestFocus();
|
||||
},
|
||||
blur: () => {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
"window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;",
|
||||
);
|
||||
Keyboard.dismiss();
|
||||
},
|
||||
}),
|
||||
[sendToWebView, streamKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
outputDecoderRef.current.decode();
|
||||
}, [streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bridgeReadyVersion <= 0) return;
|
||||
const mountMessage = createMountMessage(mountConfigRef.current);
|
||||
mountRequestedStreamKeyRef.current = streamKey;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
sendToWebView(mountMessage);
|
||||
flushPendingMessages();
|
||||
scheduleRendererReadyWatchdog();
|
||||
}, [
|
||||
bridgeReadyVersion,
|
||||
flushPendingMessages,
|
||||
scheduleRendererReadyWatchdog,
|
||||
sendToWebView,
|
||||
streamKey,
|
||||
]);
|
||||
|
||||
const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]);
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme });
|
||||
}, [sendToWebView, streamKey, themeKey, xtermTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines });
|
||||
}, [scrollbackLines, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize });
|
||||
}, [fontFamily, fontSize, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers });
|
||||
}, [pendingModifiers, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled });
|
||||
}, [sendToWebView, streamKey, swipeGesturesEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) return;
|
||||
sendToWebView({ type: "resize", streamKey, forceClaim: true });
|
||||
sendToWebView({ type: "focus", streamKey });
|
||||
webViewRef.current?.requestFocus();
|
||||
}, [focusRequestToken, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resizeRequestToken <= 0) return;
|
||||
sendToWebView({ type: "resize", streamKey, forceClaim: false, shouldClaim: false });
|
||||
}, [resizeRequestToken, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (mountRequestedStreamKeyRef.current) {
|
||||
const previousStreamKey = mountRequestedStreamKeyRef.current;
|
||||
callbacksRef.current.onRendererReadyChange?.({
|
||||
streamKey: previousStreamKey,
|
||||
isReady: false,
|
||||
});
|
||||
sendToWebView({ type: "unmount", streamKey: previousStreamKey });
|
||||
}
|
||||
bridgeReadyRef.current = false;
|
||||
pendingMessagesRef.current = [];
|
||||
mountRequestedStreamKeyRef.current = null;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
clearBridgeReadyTimeout();
|
||||
clearRendererReadyTimeout();
|
||||
};
|
||||
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]);
|
||||
|
||||
const handleLifecycleMessage = useCallback(
|
||||
(message: BridgeOutboundMessage): boolean => {
|
||||
if (message.type === "bridgeReady") {
|
||||
bridgeReadyRef.current = true;
|
||||
bridgeReadyVersionRef.current += 1;
|
||||
clearBridgeReadyTimeout();
|
||||
setBridgeReadyVersion((value) => value + 1);
|
||||
return true;
|
||||
}
|
||||
if (message.type === "rendererReady") {
|
||||
if (message.streamKey === mountRequestedStreamKeyRef.current) {
|
||||
rendererReadyStreamKeyRef.current = message.isReady ? message.streamKey : null;
|
||||
if (message.isReady) {
|
||||
rendererReadyVersionRef.current += 1;
|
||||
clearRendererReadyTimeout();
|
||||
}
|
||||
}
|
||||
callbacksRef.current.onRendererReadyChange?.({
|
||||
streamKey: message.streamKey,
|
||||
isReady: message.isReady,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[clearBridgeReadyTimeout, clearRendererReadyTimeout],
|
||||
);
|
||||
|
||||
const resolveLocalFileLink = useCallback(
|
||||
async (message: Extract<BridgeOutboundMessage, { type: "resolveLocalFileLink" }>) => {
|
||||
try {
|
||||
const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ??
|
||||
null);
|
||||
sendToWebView({
|
||||
type: "resolveLocalFileLinkResponse",
|
||||
streamKey: message.streamKey,
|
||||
requestId: message.requestId,
|
||||
target,
|
||||
});
|
||||
} catch {
|
||||
sendToWebView({
|
||||
type: "resolveLocalFileLinkResponse",
|
||||
streamKey: message.streamKey,
|
||||
requestId: message.requestId,
|
||||
target: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
[sendToWebView],
|
||||
);
|
||||
|
||||
const handleTerminalMessage = useCallback(
|
||||
(
|
||||
message: Exclude<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
|
||||
) => {
|
||||
if (message.type === "resolveLocalFileLink") {
|
||||
void resolveLocalFileLink(message);
|
||||
return;
|
||||
}
|
||||
if (message.type === "openLocalFileLink") {
|
||||
callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition);
|
||||
return;
|
||||
}
|
||||
switch (message.type) {
|
||||
case "input":
|
||||
callbacksRef.current.onInput?.(message.data);
|
||||
break;
|
||||
case "resize":
|
||||
callbacksRef.current.onResize?.({
|
||||
rows: message.rows,
|
||||
cols: message.cols,
|
||||
shouldClaim: message.shouldClaim !== false,
|
||||
forceClaim: message.forceClaim,
|
||||
});
|
||||
break;
|
||||
case "terminalKey":
|
||||
callbacksRef.current.onTerminalKey?.({
|
||||
key: message.key,
|
||||
ctrl: message.ctrl,
|
||||
shift: message.shift,
|
||||
alt: message.alt,
|
||||
meta: message.meta,
|
||||
});
|
||||
break;
|
||||
case "pendingModifiersConsumed":
|
||||
callbacksRef.current.onPendingModifiersConsumed?.();
|
||||
break;
|
||||
case "inputModeChange":
|
||||
callbacksRef.current.onInputModeChange?.(message.state);
|
||||
break;
|
||||
case "openExternalUrl":
|
||||
void openExternalUrl(message.url);
|
||||
break;
|
||||
case "swipeLeft":
|
||||
callbacksRef.current.onSwipeLeft?.();
|
||||
break;
|
||||
case "swipeRight":
|
||||
callbacksRef.current.onSwipeRight?.();
|
||||
break;
|
||||
case "debug":
|
||||
break;
|
||||
}
|
||||
},
|
||||
[resolveLocalFileLink],
|
||||
);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
let message: BridgeOutboundMessage;
|
||||
try {
|
||||
message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "bridgeReady" || message.type === "rendererReady") {
|
||||
handleLifecycleMessage(message);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!shouldHandleTerminalBridgeMessage(message, {
|
||||
desiredStreamKey: streamKey,
|
||||
mountedStreamKey: mountRequestedStreamKeyRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "findRequest") {
|
||||
callbacksRef.current.onFindRequest?.();
|
||||
return;
|
||||
}
|
||||
if (message.type === "findResult") {
|
||||
callbacksRef.current.onFindResult?.(message.result);
|
||||
return;
|
||||
}
|
||||
handleTerminalMessage(message);
|
||||
},
|
||||
[handleLifecycleMessage, handleTerminalMessage, streamKey],
|
||||
);
|
||||
|
||||
const handleLoadStart = useCallback<NonNullable<WebViewProps["onLoadStart"]>>(() => {
|
||||
bridgeReadyRef.current = false;
|
||||
mountRequestedStreamKeyRef.current = null;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
scheduleBridgeReadyWatchdog();
|
||||
}, [scheduleBridgeReadyWatchdog]);
|
||||
|
||||
const handleContentProcessDidTerminate = useCallback<
|
||||
NonNullable<WebViewProps["onContentProcessDidTerminate"]>
|
||||
>(() => {
|
||||
resetWebViewDocument();
|
||||
}, [resetWebViewDocument]);
|
||||
|
||||
const handleRenderProcessGone = useCallback<
|
||||
NonNullable<WebViewProps["onRenderProcessGone"]>
|
||||
>(() => {
|
||||
resetWebViewDocument();
|
||||
}, [resetWebViewDocument]);
|
||||
|
||||
const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => {
|
||||
pendingTapRef.current = {
|
||||
startX: event.nativeEvent.pageX,
|
||||
startY: event.nativeEvent.pageY,
|
||||
moved: false,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => {
|
||||
const pendingTap = pendingTapRef.current;
|
||||
if (!pendingTap) return;
|
||||
const dx = event.nativeEvent.pageX - pendingTap.startX;
|
||||
const dy = event.nativeEvent.pageY - pendingTap.startY;
|
||||
if (
|
||||
Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX ||
|
||||
Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX
|
||||
) {
|
||||
pendingTap.moved = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWebViewTouchEnd = useCallback(() => {
|
||||
const pendingTap = pendingTapRef.current;
|
||||
pendingTapRef.current = null;
|
||||
if (!pendingTap || pendingTap.moved) {
|
||||
return;
|
||||
}
|
||||
webViewRef.current?.requestFocus();
|
||||
callbacksRef.current.onFocus?.();
|
||||
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
|
||||
}, [sendToWebView, streamKey]);
|
||||
|
||||
const handleWebViewTouchCancel = useCallback(() => {
|
||||
pendingTapRef.current = null;
|
||||
}, []);
|
||||
|
||||
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
|
||||
const rootStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.root, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
const webViewStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.webView, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
const webViewContainerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.webViewContainer, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={rootStyle} testID={testId}>
|
||||
<WebView
|
||||
key={webViewEpoch}
|
||||
ref={webViewRef}
|
||||
source={TERMINAL_WEBVIEW_SOURCE}
|
||||
style={webViewStyle}
|
||||
containerStyle={webViewContainerStyle}
|
||||
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
|
||||
scrollEnabled
|
||||
nestedScrollEnabled
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
textInteractionEnabled
|
||||
allowsLinkPreview={false}
|
||||
setSupportMultipleWindows={false}
|
||||
setBuiltInZoomControls={false}
|
||||
setDisplayZoomControls={false}
|
||||
textZoom={100}
|
||||
onTouchStart={handleWebViewTouchStart}
|
||||
onTouchMove={handleWebViewTouchMove}
|
||||
onTouchEnd={handleWebViewTouchEnd}
|
||||
onTouchCancel={handleWebViewTouchCancel}
|
||||
onMessage={handleMessage}
|
||||
onLoadStart={handleLoadStart}
|
||||
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||
onRenderProcessGone={handleRenderProcessGone}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
webViewContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -2,10 +2,8 @@ import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useAppSettings } from "../hooks/use-settings";
|
||||
import { resolveTerminalRendererSelection } from "../terminal/native-renderer/terminal-renderer-capability";
|
||||
import NativeGridTerminalEmulator from "./terminal-emulator-native-grid.native";
|
||||
import WebViewTerminalEmulator from "./terminal-emulator-webview.native";
|
||||
import type { TerminalEmulatorProps } from "./terminal-emulator-contract";
|
||||
|
||||
export type { TerminalEmulatorHandle } from "./terminal-emulator-contract";
|
||||
@@ -25,10 +23,7 @@ function UnsupportedNativeTerminal({ streamKey, onRendererReadyChange }: Termina
|
||||
}
|
||||
|
||||
export default function TerminalEmulator(props: TerminalEmulatorProps) {
|
||||
const { settings } = useAppSettings();
|
||||
const { useLegacyTerminalRenderer } = settings;
|
||||
const rendererSelection = resolveTerminalRendererSelection({
|
||||
useLegacyTerminalRenderer,
|
||||
supportsTerminalInputModeReplay: props.supportsTerminalInputModeReplay,
|
||||
});
|
||||
|
||||
@@ -36,10 +31,7 @@ export default function TerminalEmulator(props: TerminalEmulatorProps) {
|
||||
return <UnsupportedNativeTerminal {...props} />;
|
||||
}
|
||||
|
||||
const Renderer =
|
||||
rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator;
|
||||
|
||||
return <Renderer key={rendererSelection} {...props} />;
|
||||
return <NativeGridTerminalEmulator {...props} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -11,9 +11,7 @@ import {
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type DragEvent as ReactDragEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type Ref,
|
||||
} from "react";
|
||||
@@ -29,6 +27,7 @@ import {
|
||||
type TerminalOutputData,
|
||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
|
||||
import type { TerminalFile } from "../terminal/file-paste";
|
||||
import type {
|
||||
TerminalLocalFileLinkSource,
|
||||
TerminalLocalFileLinkTarget,
|
||||
@@ -37,13 +36,6 @@ import type { TerminalClipboardWriter } from "../terminal/native-renderer/termin
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
import { openExternalUrl } from "../utils/open-external-url";
|
||||
import { focusWithRetries } from "../utils/web-focus";
|
||||
import {
|
||||
extractTerminalDropPaths,
|
||||
isTerminalDragLeaveOutside,
|
||||
isTerminalFileDrag,
|
||||
prepareDroppedPathsForTerminal,
|
||||
} from "../terminal/drop/terminal-file-drop";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
export interface TerminalEmulatorHandle {
|
||||
find?: TerminalFindHandle;
|
||||
@@ -145,6 +137,7 @@ interface TerminalEmulatorProps {
|
||||
disposition: "main" | "side",
|
||||
) => Promise<void> | void;
|
||||
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||
onFilePaste?: (file: Promise<TerminalFile | null>) => void;
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
resizeRequestToken?: number;
|
||||
@@ -191,6 +184,7 @@ export default function TerminalEmulator({
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onRendererReadyChange,
|
||||
onFilePaste,
|
||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||
focusRequestToken = 0,
|
||||
resizeRequestToken = 0,
|
||||
@@ -220,6 +214,7 @@ export default function TerminalEmulator({
|
||||
onInputModeChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onFilePaste,
|
||||
});
|
||||
mountCallbacksRef.current = {
|
||||
onFindRequest,
|
||||
@@ -231,13 +226,12 @@ export default function TerminalEmulator({
|
||||
onInputModeChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onFilePaste,
|
||||
};
|
||||
const initialSnapshotRef = useRef(initialSnapshot);
|
||||
initialSnapshotRef.current = initialSnapshot;
|
||||
const pendingModifiersRef = useRef(pendingModifiers);
|
||||
pendingModifiersRef.current = pendingModifiers;
|
||||
const [isDropActive, setIsDropActive] = useState(false);
|
||||
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
|
||||
const pasteText = useCallback((text: string) => {
|
||||
@@ -506,11 +500,13 @@ export default function TerminalEmulator({
|
||||
onInputModeChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onFilePaste,
|
||||
onOpenExternalUrl: openExternalUrl,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
onFindRequest,
|
||||
onFilePaste,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onInputModeChange,
|
||||
@@ -582,111 +578,6 @@ export default function TerminalEmulator({
|
||||
[showTerminalContextMenu],
|
||||
);
|
||||
|
||||
const clearDropActiveTimeout = useCallback(() => {
|
||||
if (dropActiveTimeoutRef.current === null) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(dropActiveTimeoutRef.current);
|
||||
dropActiveTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearTerminalDropActive = useCallback(() => {
|
||||
clearDropActiveTimeout();
|
||||
setIsDropActive(false);
|
||||
}, [clearDropActiveTimeout]);
|
||||
|
||||
const keepTerminalDropActive = useCallback(() => {
|
||||
clearDropActiveTimeout();
|
||||
setIsDropActive(true);
|
||||
dropActiveTimeoutRef.current = setTimeout(() => {
|
||||
dropActiveTimeoutRef.current = null;
|
||||
setIsDropActive(false);
|
||||
}, 180);
|
||||
}, [clearDropActiveTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
if (!isTerminalFileDrag(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
keepTerminalDropActive();
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
if (!isTerminalFileDrag(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
keepTerminalDropActive();
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (!isTerminalFileDrag(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearTerminalDropActive();
|
||||
|
||||
const bridge = getDesktopHost();
|
||||
const paths = extractTerminalDropPaths(event.dataTransfer, bridge);
|
||||
if (paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeRef.current?.focus();
|
||||
mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths, bridge));
|
||||
};
|
||||
|
||||
root.addEventListener("dragenter", handleDragEnter, { capture: true });
|
||||
root.addEventListener("dragover", handleDragOver, { capture: true });
|
||||
root.addEventListener("drop", handleDrop, { capture: true });
|
||||
window.addEventListener("dragend", clearTerminalDropActive);
|
||||
window.addEventListener("drop", clearTerminalDropActive);
|
||||
|
||||
return () => {
|
||||
root.removeEventListener("dragenter", handleDragEnter, { capture: true });
|
||||
root.removeEventListener("dragover", handleDragOver, { capture: true });
|
||||
root.removeEventListener("drop", handleDrop, { capture: true });
|
||||
window.removeEventListener("dragend", clearTerminalDropActive);
|
||||
window.removeEventListener("drop", clearTerminalDropActive);
|
||||
clearDropActiveTimeout();
|
||||
};
|
||||
}, [clearDropActiveTimeout, clearTerminalDropActive, keepTerminalDropActive]);
|
||||
|
||||
const handleRootDragLeave = useCallback(
|
||||
(event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!isTerminalFileDrag(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (
|
||||
!isTerminalDragLeaveOutside({
|
||||
currentTarget: event.currentTarget,
|
||||
relatedTarget: event.relatedTarget,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
clearTerminalDropActive();
|
||||
},
|
||||
[clearTerminalDropActive],
|
||||
);
|
||||
|
||||
const rootDivStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
position: "relative",
|
||||
@@ -702,19 +593,6 @@ export default function TerminalEmulator({
|
||||
}),
|
||||
[xtermTheme.background],
|
||||
);
|
||||
const dropOverlayStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 9,
|
||||
border: "1px solid rgba(78, 161, 255, 0.72)",
|
||||
backgroundColor: "rgba(78, 161, 255, 0.16)",
|
||||
opacity: isDropActive ? 1 : 0,
|
||||
pointerEvents: "none",
|
||||
transition: "opacity 120ms ease-out",
|
||||
}),
|
||||
[isDropActive],
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
@@ -723,10 +601,8 @@ export default function TerminalEmulator({
|
||||
style={rootDivStyle}
|
||||
onPointerDown={handleRootPointerDown}
|
||||
onContextMenu={handleRootContextMenu}
|
||||
onDragLeave={handleRootDragLeave}
|
||||
>
|
||||
<div ref={hostRef} style={HOST_DIV_STYLE} />
|
||||
<div style={dropOverlayStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,23 @@ import {
|
||||
shouldShowTerminalPasteAction,
|
||||
type TerminalVirtualKeyboardControl,
|
||||
} from "@/terminal/runtime/terminal-virtual-keyboard";
|
||||
import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
|
||||
import { encodeTerminalPaste, pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
|
||||
import {
|
||||
TerminalFilePasteQueue,
|
||||
TerminalFileTooLargeError,
|
||||
terminalFileFromBase64,
|
||||
type TerminalFile,
|
||||
} from "@/terminal/file-paste";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { DismissToast } from "@/components/toast-host";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { useFileDrop } from "@/components/file-drop/use-file-drop";
|
||||
import type { DroppedItem } from "@/components/file-drop/types";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
|
||||
import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
|
||||
import {
|
||||
EMPTY_FOCUS_CLAIM_STATE,
|
||||
@@ -122,6 +138,24 @@ type PendingTerminalInput =
|
||||
input: TerminalKeyInput;
|
||||
};
|
||||
|
||||
async function readNativeClipboardImage(): Promise<TerminalFile | null> {
|
||||
const image = await Clipboard.getImageAsync({ format: "png" });
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
return terminalFileFromBase64({
|
||||
fileName: "clipboard.png",
|
||||
mimeType: "image/png",
|
||||
data: image.data.slice(image.data.indexOf(",") + 1),
|
||||
});
|
||||
}
|
||||
|
||||
/** Receives files dropped on the pane's FileDropZone; the pane routes them by host locality. */
|
||||
function TerminalFileDropSink({ onFiles }: { onFiles: (items: DroppedItem[]) => void }) {
|
||||
useFileDrop({ onGenericFiles: onFiles });
|
||||
return null;
|
||||
}
|
||||
|
||||
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
||||
return `${input.serverId}:${input.cwd}`;
|
||||
}
|
||||
@@ -292,9 +326,47 @@ export function TerminalPane({
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [hasClipboardText, setHasClipboardText] = useState(false);
|
||||
const [hasClipboardImage, setHasClipboardImage] = useState(false);
|
||||
const [isKeyboardToggleVisible, setIsKeyboardToggleVisible] = useState(false);
|
||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||
const toast = useToast();
|
||||
const dismissUploadingToastRef = useRef<DismissToast | null>(null);
|
||||
const showFileUploading = useStableEvent((uploading: boolean) => {
|
||||
dismissUploadingToastRef.current?.();
|
||||
dismissUploadingToastRef.current = uploading
|
||||
? toast.show(t("workspace.terminal.uploadingFile"), {
|
||||
durationMs: null,
|
||||
testID: "terminal-file-uploading",
|
||||
})
|
||||
: null;
|
||||
});
|
||||
const reportFileUploaded = useStableEvent(() => {
|
||||
toast.show(t("workspace.terminal.fileUploaded"), { variant: "success" });
|
||||
});
|
||||
const reportFilePasteFailure = useStableEvent((error: unknown) => {
|
||||
const reason =
|
||||
error instanceof TerminalFileTooLargeError
|
||||
? t("composer.errors.fileTooLarge", { size: "50MB", fileName: error.fileName })
|
||||
: toErrorMessage(error);
|
||||
toast.error(t("workspace.terminal.pasteFileFailed", { reason }));
|
||||
});
|
||||
const [filePastes] = useState(
|
||||
() =>
|
||||
new TerminalFilePasteQueue({
|
||||
onUploadingChange: showFileUploading,
|
||||
onUploaded: reportFileUploaded,
|
||||
onFailure: reportFilePasteFailure,
|
||||
}),
|
||||
);
|
||||
useEffect(() => () => void filePastes.cancel(), [filePastes, terminalStreamKey]);
|
||||
useEffect(() => {
|
||||
if (!isConnected && filePastes.cancel()) {
|
||||
reportFilePasteFailure(new Error(t("workspace.terminal.hostDisconnected")));
|
||||
}
|
||||
}, [filePastes, isConnected, reportFilePasteFailure, t]);
|
||||
// Only the desktop app's own daemon can read files on this machine by path.
|
||||
const isLocalHost = useIsLocalDaemon(serverId);
|
||||
useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection);
|
||||
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
|
||||
const findRef = useRef<TerminalPaneFindHandle>(null);
|
||||
@@ -325,10 +397,15 @@ export function TerminalPane({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const hasText = await Clipboard.hasStringAsync();
|
||||
const [hasText, hasImage] = await Promise.all([
|
||||
Clipboard.hasStringAsync(),
|
||||
Clipboard.hasImageAsync(),
|
||||
]);
|
||||
setHasClipboardText(hasText);
|
||||
setHasClipboardImage(hasImage);
|
||||
} catch {
|
||||
setHasClipboardText(false);
|
||||
setHasClipboardImage(false);
|
||||
}
|
||||
}, [showVirtualKeyBar]);
|
||||
|
||||
@@ -705,6 +782,9 @@ export function TerminalPane({
|
||||
alt: boolean;
|
||||
meta?: boolean;
|
||||
}): boolean => {
|
||||
if (input.ctrl && input.key.toLowerCase() === "c") {
|
||||
filePastes.cancel();
|
||||
}
|
||||
if (!client || !terminalIdRef.current) {
|
||||
enqueuePendingTerminalInput({
|
||||
type: "key",
|
||||
@@ -740,7 +820,7 @@ export function TerminalPane({
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput],
|
||||
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, filePastes],
|
||||
);
|
||||
|
||||
const handleTerminalData = useCallback(
|
||||
@@ -748,6 +828,10 @@ export function TerminalPane({
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Ctrl+C abandons file pastes that have not landed yet.
|
||||
if (data.includes("\x03")) {
|
||||
filePastes.cancel();
|
||||
}
|
||||
|
||||
if (hasPendingTerminalModifiers(modifiers)) {
|
||||
const pendingResolution = resolvePendingModifierDataInput({
|
||||
@@ -793,12 +877,59 @@ export function TerminalPane({
|
||||
clearPendingModifiers,
|
||||
client,
|
||||
dispatchTerminalInputEntry,
|
||||
filePastes,
|
||||
modifiers,
|
||||
sendTerminalKey,
|
||||
enqueuePendingTerminalInput,
|
||||
],
|
||||
);
|
||||
|
||||
const pasteTerminalText = useStableEvent((text: string) => {
|
||||
const entry: PendingTerminalInput = {
|
||||
type: "data",
|
||||
data: encodeTerminalPaste({
|
||||
text,
|
||||
bracketedPaste: inputModeRef.current.bracketedPaste ?? false,
|
||||
}),
|
||||
};
|
||||
if (!dispatchTerminalInputEntry(entry)) {
|
||||
enqueuePendingTerminalInput(entry);
|
||||
}
|
||||
});
|
||||
|
||||
const handleFilePaste = useStableEvent((file: Promise<TerminalFile | null>) => {
|
||||
filePastes.add(file, {
|
||||
upload: async (upload) => {
|
||||
if (!client || !isConnected) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const response = await client.uploadFile(upload);
|
||||
if (!response.file) {
|
||||
throw new Error(response.error ?? "Upload failed");
|
||||
}
|
||||
return response.file.path;
|
||||
},
|
||||
paste: pasteTerminalText,
|
||||
});
|
||||
});
|
||||
|
||||
const handleDroppedFiles = useStableEvent((items: DroppedItem[]) => {
|
||||
const bridge = getDesktopHost();
|
||||
const paths: string[] = [];
|
||||
for (const item of items) {
|
||||
const delivery = routeTerminalDrop(item, { hostIsLocal: isLocalHost, bridge });
|
||||
if (delivery.kind === "path") {
|
||||
paths.push(delivery.path);
|
||||
} else {
|
||||
handleFilePaste(delivery.read());
|
||||
}
|
||||
}
|
||||
if (paths.length > 0) {
|
||||
pasteTerminalText(prepareDroppedPathsForTerminal(paths));
|
||||
}
|
||||
requestTerminalFocus();
|
||||
});
|
||||
|
||||
const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => {
|
||||
const nextSize = { rows: input.rows, cols: input.cols };
|
||||
const claim = resolveTerminalResizeClaim({
|
||||
@@ -876,11 +1007,15 @@ export function TerminalPane({
|
||||
|
||||
const handleTerminalPaste = useCallback(() => {
|
||||
requestTerminalReflow();
|
||||
void pasteTerminalClipboard({
|
||||
const image = pasteTerminalClipboard({
|
||||
clipboard: { readText: () => Clipboard.getStringAsync() },
|
||||
terminal: { paste: (text) => emulatorRef.current?.paste(text) },
|
||||
}).then(() => refreshClipboardAvailability());
|
||||
}, [requestTerminalReflow, refreshClipboardAvailability]);
|
||||
})
|
||||
.catch(() => false)
|
||||
.then((pastedText) => (pastedText ? null : readNativeClipboardImage()));
|
||||
handleFilePaste(image);
|
||||
void image.catch(() => null).then(() => refreshClipboardAvailability());
|
||||
}, [handleFilePaste, requestTerminalReflow, refreshClipboardAvailability]);
|
||||
|
||||
const handleTerminalCopy = useCallback(() => {
|
||||
void emulatorRef.current
|
||||
@@ -1032,7 +1167,7 @@ export function TerminalPane({
|
||||
return showPasteAction ? (
|
||||
<TerminalPasteAction
|
||||
key={controlId}
|
||||
hasClipboardText={hasClipboardText}
|
||||
hasClipboardContent={hasClipboardText || hasClipboardImage}
|
||||
onPaste={handleTerminalPaste}
|
||||
/>
|
||||
) : null;
|
||||
@@ -1065,7 +1200,8 @@ export function TerminalPane({
|
||||
|
||||
return (
|
||||
<Animated.View style={containerStyle}>
|
||||
<View style={styles.outputContainer}>
|
||||
<FileDropZone style={styles.outputContainer}>
|
||||
<TerminalFileDropSink onFiles={handleDroppedFiles} />
|
||||
<View style={styles.terminalGestureContainer}>
|
||||
<TerminalEmulator
|
||||
ref={emulatorRef}
|
||||
@@ -1090,6 +1226,7 @@ export function TerminalPane({
|
||||
onFocus={handleTerminalFocus}
|
||||
onResize={handleTerminalResize}
|
||||
onTerminalKey={handleTerminalKey}
|
||||
onFilePaste={handleFilePaste}
|
||||
onInputModeChange={handleInputModeChange}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onResolveLocalFileLink={handleResolveLocalFileLink}
|
||||
@@ -1122,7 +1259,7 @@ export function TerminalPane({
|
||||
<TerminalFloatingCopyAction hasSelection={hasSelection} onCopy={handleTerminalCopy} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
{streamError ? (
|
||||
<View style={styles.errorRow}>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing";
|
||||
|
||||
describe("terminal WebView message routing", () => {
|
||||
it("rejects terminal messages from a stale stream", () => {
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "input", streamKey: "terminal-a" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "resize", streamKey: "terminal-a" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the old WebView stream while React is switching callbacks", () => {
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "input", streamKey: "terminal-a" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-a" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts messages from the mounted stream and unscoped diagnostics", () => {
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "input", streamKey: "terminal-b" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "debug" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
interface TerminalBridgeMessageIdentity {
|
||||
type: string;
|
||||
streamKey?: string;
|
||||
}
|
||||
|
||||
export function shouldHandleTerminalBridgeMessage(
|
||||
message: TerminalBridgeMessageIdentity,
|
||||
stream: {
|
||||
desiredStreamKey: string;
|
||||
mountedStreamKey: string | null;
|
||||
},
|
||||
): boolean {
|
||||
return (
|
||||
message.streamKey === undefined ||
|
||||
(message.streamKey === stream.desiredStreamKey && message.streamKey === stream.mountedStreamKey)
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness";
|
||||
|
||||
describe("legacy terminal WebView readiness", () => {
|
||||
it("resets when a requested mount never reports renderer readiness", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 0,
|
||||
expectedStreamKey: "terminal-a",
|
||||
mountRequestedStreamKey: "terminal-a",
|
||||
rendererReadyStreamKey: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the document after the requested renderer confirms readiness", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 0,
|
||||
expectedStreamKey: "terminal-a",
|
||||
mountRequestedStreamKey: "terminal-a",
|
||||
rendererReadyStreamKey: "terminal-a",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a watchdog from an earlier ready generation", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 1,
|
||||
expectedStreamKey: "terminal-a",
|
||||
mountRequestedStreamKey: "terminal-a",
|
||||
rendererReadyStreamKey: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let readiness from a stale stream satisfy the requested mount", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 0,
|
||||
expectedStreamKey: "terminal-b",
|
||||
mountRequestedStreamKey: "terminal-b",
|
||||
rendererReadyStreamKey: "terminal-a",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reset a newer mount from an expired watchdog", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 0,
|
||||
expectedStreamKey: "terminal-a",
|
||||
mountRequestedStreamKey: "terminal-b",
|
||||
rendererReadyStreamKey: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
export function shouldResetForMissingRendererReady(input: {
|
||||
expectedReadyVersion: number;
|
||||
currentReadyVersion: number;
|
||||
expectedStreamKey: string;
|
||||
mountRequestedStreamKey: string | null;
|
||||
rendererReadyStreamKey: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
input.currentReadyVersion === input.expectedReadyVersion &&
|
||||
input.mountRequestedStreamKey === input.expectedStreamKey &&
|
||||
input.rendererReadyStreamKey !== input.expectedStreamKey
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useToastHost } from "./toast-host";
|
||||
|
||||
describe("useToastHost", () => {
|
||||
it("dismisses the toast that show returned the dismiss for", () => {
|
||||
const { result } = renderHook(() => useToastHost());
|
||||
|
||||
let dismiss = () => {};
|
||||
act(() => {
|
||||
dismiss = result.current.api.show("Uploading image…", { durationMs: null });
|
||||
});
|
||||
expect(result.current.toast?.content).toBe("Uploading image…");
|
||||
|
||||
act(() => dismiss());
|
||||
expect(result.current.toast).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a newer toast in place when an older one is dismissed", () => {
|
||||
const { result } = renderHook(() => useToastHost());
|
||||
|
||||
let dismissUploading = () => {};
|
||||
act(() => {
|
||||
dismissUploading = result.current.api.show("Uploading image…", { durationMs: null });
|
||||
});
|
||||
act(() => {
|
||||
result.current.api.show("Image uploaded", { variant: "success" });
|
||||
});
|
||||
|
||||
act(() => dismissUploading());
|
||||
expect(result.current.toast?.content).toBe("Image uploaded");
|
||||
});
|
||||
});
|
||||
@@ -43,8 +43,11 @@ export interface ToastState {
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/** Dismisses the toast it was returned for; a no-op once another toast replaced it. */
|
||||
export type DismissToast = () => void;
|
||||
|
||||
export interface ToastApi {
|
||||
show: (content: ReactNode, options?: ToastShowOptions) => void;
|
||||
show: (content: ReactNode, options?: ToastShowOptions) => DismissToast;
|
||||
copied: (label?: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
@@ -52,6 +55,7 @@ export interface ToastApi {
|
||||
type ToastViewportPlacement = "app-shell" | "panel";
|
||||
|
||||
const DEFAULT_DURATION_MS = 2200;
|
||||
const noopDismiss: DismissToast = () => {};
|
||||
const TOAST_MAX_WIDTH = 480;
|
||||
const toastEntering = FadeIn.duration(140);
|
||||
const toastExiting = FadeOut.duration(140);
|
||||
@@ -73,10 +77,10 @@ export function useToastHost(): {
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const idRef = useRef(0);
|
||||
|
||||
const show = useCallback((content: ReactNode, options?: ToastShowOptions) => {
|
||||
const show = useCallback((content: ReactNode, options?: ToastShowOptions): DismissToast => {
|
||||
const nativeMessage = typeof content === "string" ? content.trim() : null;
|
||||
if (!content || nativeMessage === "") {
|
||||
return;
|
||||
return noopDismiss;
|
||||
}
|
||||
|
||||
const variant = options?.variant ?? "default";
|
||||
@@ -87,12 +91,13 @@ export function useToastHost(): {
|
||||
const duration =
|
||||
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
|
||||
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
|
||||
return;
|
||||
return noopDismiss;
|
||||
}
|
||||
|
||||
idRef.current += 1;
|
||||
const id = idRef.current;
|
||||
setToast({
|
||||
id: idRef.current,
|
||||
id,
|
||||
content,
|
||||
nativeMessage,
|
||||
icon: options?.icon,
|
||||
@@ -100,6 +105,7 @@ export function useToastHost(): {
|
||||
durationMs,
|
||||
testID: options?.testID,
|
||||
});
|
||||
return () => setToast((current) => (current?.id === id ? null : current));
|
||||
}, []);
|
||||
|
||||
const api = useMemo<ToastApi>(
|
||||
|
||||
@@ -31,6 +31,8 @@ function workspaceDescriptor(input: {
|
||||
statusEnteredAt?: Date | null;
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
@@ -86,7 +88,6 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
});
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
});
|
||||
useSidebarViewStore.setState({
|
||||
groupMode: "project",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SelectedFile } from "@/attachments/selected-file";
|
||||
import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit";
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
@@ -131,7 +132,7 @@ export async function uploadFileAttachments(input: {
|
||||
|
||||
for (const file of input.files) {
|
||||
const bytes = await file.readBytes();
|
||||
if (bytes.byteLength > 50 * 1024 * 1024) {
|
||||
if (bytes.byteLength > FILE_UPLOAD_MAX_BYTES) {
|
||||
throw new Error(
|
||||
i18n.t("composer.errors.fileTooLarge", { size: "50MB", fileName: file.fileName }),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,8 @@ const SERVER_ID = "diff-stat-pill";
|
||||
const WORKSPACE_ID = "workspace";
|
||||
|
||||
const workspace: WorkspaceDescriptor = {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: WORKSPACE_ID,
|
||||
projectId: "project",
|
||||
projectDisplayName: "Project",
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
|
||||
const baseWorkspace: WorkspaceDescriptor = {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: "/repo/worktree",
|
||||
projectId: "/repo",
|
||||
projectDisplayName: "Repo",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
buildSidebarProjectsFromStructure,
|
||||
computeSidebarOrderUpdates,
|
||||
createSidebarWorkspacePlacementSelector,
|
||||
createSidebarWorkspaceEntry,
|
||||
deriveProjectStatusBucket,
|
||||
deriveSidebarLoadingState,
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
|
||||
function workspaceWithForge(forge: string | undefined, prUrl: string): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: "ws-1",
|
||||
projectId: "proj",
|
||||
projectDisplayName: "repo",
|
||||
@@ -150,6 +153,8 @@ function workspace(input: {
|
||||
statusEnteredAt?: Date | null;
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName,
|
||||
@@ -610,13 +615,12 @@ describe("computeSidebarOrderUpdates", () => {
|
||||
const updates = computeSidebarOrderUpdates({
|
||||
projects: [],
|
||||
persistedProjectOrder: ["stale-project"],
|
||||
getWorkspaceOrder: () => [],
|
||||
});
|
||||
|
||||
expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] });
|
||||
expect(updates).toEqual({ projectOrder: null });
|
||||
});
|
||||
|
||||
it("appends unseen projects while putting unseen workspaces before the saved order", () => {
|
||||
it("appends unseen projects without changing saved project order", () => {
|
||||
const projects = [
|
||||
sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1", "ws-2"] }),
|
||||
sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-3"] }),
|
||||
@@ -625,36 +629,9 @@ describe("computeSidebarOrderUpdates", () => {
|
||||
const updates = computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder: ["project-a"],
|
||||
getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : []),
|
||||
});
|
||||
|
||||
expect(updates.projectOrder).toEqual(["project-a", "project-b"]);
|
||||
expect(updates.workspaceOrders).toEqual([
|
||||
{ projectViewKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] },
|
||||
{ projectViewKey: "project-b", order: ["srv:ws-3"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the saved workspace order behind multiple newly discovered workspaces", () => {
|
||||
const projects = [
|
||||
sidebarProject({
|
||||
projectKey: "project-a",
|
||||
workspaceKeys: ["newest", "newer", "old-a", "old-b"],
|
||||
}),
|
||||
];
|
||||
|
||||
const updates = computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder: ["project-a"],
|
||||
getWorkspaceOrder: () => ["srv:old-b", "srv:old-a"],
|
||||
});
|
||||
|
||||
expect(updates.workspaceOrders).toEqual([
|
||||
{
|
||||
projectViewKey: "project-a",
|
||||
order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns no project-order update when persisted order already covers visible keys", () => {
|
||||
@@ -666,11 +643,9 @@ describe("computeSidebarOrderUpdates", () => {
|
||||
const updates = computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder: ["project-b", "project-a"],
|
||||
getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : ["srv:ws-2"]),
|
||||
});
|
||||
|
||||
expect(updates.projectOrder).toBeNull();
|
||||
expect(updates.workspaceOrders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -993,3 +968,14 @@ describe("deriveProjectStatusBucket", () => {
|
||||
).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
it("retains unrelated projects and keyed row placements when one project reorders", () => {
|
||||
const select = createSidebarWorkspacePlacementSelector();
|
||||
const a = project({ projectKey: "a", workspaceKeys: ["srv:a1", "srv:a2"] });
|
||||
const b = project({ projectKey: "b", workspaceKeys: ["srv:b1"] });
|
||||
const before = select([a, b]);
|
||||
const after = select([{ ...a, workspaceKeys: ["srv:a2", "srv:a1"] }, b]);
|
||||
expect(after.projects[1]).toBe(before.projects[1]);
|
||||
expect(after.projects[0].workspaces[0]).toBe(before.projects[0].workspaces[1]);
|
||||
expect(after.projects[0].workspaces[1]).toBe(before.projects[0].workspaces[0]);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
import { selectPrHintFromStatus } from "@/git/pr-hint";
|
||||
import { type HostProjectListItem } from "@/projects/host-project-model";
|
||||
@@ -287,6 +288,34 @@ export function deriveProjectStatusBucket(input: {
|
||||
return aggregateSidebarStateBuckets(buckets);
|
||||
}
|
||||
|
||||
/** One bounded projection per sidebar; unchanged placements survive project reordering. */
|
||||
export function createSidebarWorkspacePlacementSelector(): (
|
||||
projects: readonly HostProjectListItem[],
|
||||
) => SidebarWorkspacePlacementModel {
|
||||
let previous: SidebarWorkspacePlacementModel | null = null;
|
||||
return (projects) => {
|
||||
const next = buildSidebarWorkspacePlacementModel({ projects });
|
||||
const previousProjects = new Map(
|
||||
previous?.projects.map((project) => [project.viewKey, project]),
|
||||
);
|
||||
const previousWorkspaces = new Map(
|
||||
previous?.workspaces.map((workspace) => [workspace.workspaceKey, workspace]),
|
||||
);
|
||||
next.projects = next.projects.map((project) => {
|
||||
const prior = previousProjects.get(project.viewKey);
|
||||
if (prior && equal(prior, project)) return prior;
|
||||
project.workspaces = project.workspaces.map((workspace) => {
|
||||
const priorWorkspace = previousWorkspaces.get(workspace.workspaceKey);
|
||||
return priorWorkspace && equal(priorWorkspace, workspace) ? priorWorkspace : workspace;
|
||||
});
|
||||
return project;
|
||||
});
|
||||
next.workspaces = next.projects.flatMap((project) => project.workspaces);
|
||||
previous = next;
|
||||
return next;
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarWorkspacePlacementModel(input: {
|
||||
projects: readonly HostProjectListItem[];
|
||||
}): SidebarWorkspacePlacementModel {
|
||||
@@ -555,56 +584,21 @@ export function appendMissingOrderKeys(input: {
|
||||
return [...input.currentOrder, ...missingKeys];
|
||||
}
|
||||
|
||||
export function prependMissingOrderKeys(input: {
|
||||
currentOrder: string[];
|
||||
visibleKeys: string[];
|
||||
}): string[] {
|
||||
if (input.visibleKeys.length === 0) {
|
||||
return input.currentOrder;
|
||||
}
|
||||
|
||||
const existingKeys = new Set(input.currentOrder);
|
||||
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key));
|
||||
if (missingKeys.length === 0) {
|
||||
return input.currentOrder;
|
||||
}
|
||||
|
||||
return [...missingKeys, ...input.currentOrder];
|
||||
}
|
||||
|
||||
export interface SidebarOrderUpdates {
|
||||
projectOrder: string[] | null;
|
||||
workspaceOrders: Array<{ projectViewKey: string; order: string[] }>;
|
||||
}
|
||||
|
||||
export function computeSidebarOrderUpdates(input: {
|
||||
projects: SidebarProjectEntry[];
|
||||
persistedProjectOrder: string[];
|
||||
getWorkspaceOrder: (projectViewKey: string) => string[];
|
||||
}): SidebarOrderUpdates {
|
||||
if (input.projects.length === 0) {
|
||||
return { projectOrder: null, workspaceOrders: [] };
|
||||
}
|
||||
|
||||
const nextProjectOrder = appendMissingOrderKeys({
|
||||
currentOrder: input.persistedProjectOrder,
|
||||
visibleKeys: input.projects.map((project) => project.viewKey),
|
||||
});
|
||||
const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder;
|
||||
|
||||
const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = [];
|
||||
for (const project of input.projects) {
|
||||
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey);
|
||||
const nextWorkspaceOrder = prependMissingOrderKeys({
|
||||
currentOrder: persistedWorkspaceOrder,
|
||||
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
||||
});
|
||||
if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
|
||||
workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder });
|
||||
}
|
||||
}
|
||||
|
||||
return { projectOrder, workspaceOrders };
|
||||
return {
|
||||
projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SidebarLoadingState {
|
||||
|
||||
@@ -56,6 +56,7 @@ function createToast(): FakeToast {
|
||||
shown,
|
||||
show: (message, options) => {
|
||||
shown.push({ message, options });
|
||||
return () => {};
|
||||
},
|
||||
copied: () => {},
|
||||
error: () => {},
|
||||
|
||||
@@ -389,26 +389,6 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
expect(legacySide.openInSidePane).not.toHaveProperty("pullRequests");
|
||||
});
|
||||
|
||||
it("uses the native terminal renderer by default", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.useLegacyTerminalRenderer).toBe(false);
|
||||
});
|
||||
|
||||
it("loads the per-device legacy terminal renderer preference", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ useLegacyTerminalRenderer: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.useLegacyTerminalRenderer).toBe(true);
|
||||
});
|
||||
|
||||
it("loads configured terminal scrollback lines from app settings", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
|
||||
@@ -81,7 +81,6 @@ export interface AppSettings {
|
||||
sendBehavior: SendBehavior;
|
||||
serviceUrlBehavior: ServiceUrlBehavior;
|
||||
terminalScrollbackLines: number;
|
||||
useLegacyTerminalRenderer: boolean;
|
||||
uiFontFamily: string; // "" = platform default UI stack
|
||||
monoFontFamily: string; // "" = platform default mono stack
|
||||
uiBaseFontSize: number; // clamped px, platform default 14 or 15
|
||||
@@ -142,7 +141,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sendBehavior: "steer",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||
useLegacyTerminalRenderer: false,
|
||||
uiFontFamily: "",
|
||||
monoFontFamily: "",
|
||||
uiBaseFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
@@ -224,7 +222,6 @@ const StoredAppSettingsSchema = z
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
).catch(DEFAULT_TERMINAL_SCROLLBACK_LINES),
|
||||
useLegacyTerminalRenderer: z.boolean().catch(false),
|
||||
uiFontFamily: sanitizedFontFamily().catch(""),
|
||||
monoFontFamily: sanitizedFontFamily().catch(""),
|
||||
uiBaseFontSize: clampedNumber(MIN_UI_BASE_FONT_SIZE, MAX_UI_BASE_FONT_SIZE)
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useHostFeatureMap } from "@/runtime/host-features";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks";
|
||||
import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
|
||||
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
createSidebarWorkspacePlacementSelector,
|
||||
computeSidebarOrderUpdates,
|
||||
createSidebarWorkspaceEntry,
|
||||
deriveProjectStatusBucket,
|
||||
@@ -85,6 +86,7 @@ const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = [];
|
||||
const EMPTY_PROJECT_NAMES = new Map<string, string>();
|
||||
|
||||
export interface SidebarWorkspacesListResult {
|
||||
workspaceSortSupported: boolean;
|
||||
workspacePlacements: SidebarWorkspacePlacement[];
|
||||
projects: SidebarProjectEntry[];
|
||||
projectNamesByViewKey: Map<string, string>;
|
||||
@@ -138,14 +140,17 @@ export function useSidebarWorkspacesList(options?: {
|
||||
|
||||
const directoryServerIds = useWorkspaceDirectoryServerIds(serverIds);
|
||||
|
||||
const hostProjects = useHostProjects(directoryServerIds);
|
||||
const workspaceSort = useSidebarViewStore((state) => state.workspaceSort);
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes");
|
||||
const workspaceSortSupported = [...sortSupport.values()].every(Boolean);
|
||||
const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined;
|
||||
const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort);
|
||||
|
||||
const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []);
|
||||
const sidebarModel = useMemo(
|
||||
() =>
|
||||
buildSidebarWorkspacePlacementModel({
|
||||
projects: hostProjects,
|
||||
}),
|
||||
[hostProjects],
|
||||
() => selectPlacements(hostProjects),
|
||||
[hostProjects, selectPlacements],
|
||||
);
|
||||
|
||||
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
|
||||
@@ -161,16 +166,11 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const updates = computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder,
|
||||
getWorkspaceOrder: (projectViewKey) =>
|
||||
orderStore.workspaceOrderByProject[projectViewKey] ?? EMPTY_ORDER,
|
||||
});
|
||||
|
||||
if (updates.projectOrder) {
|
||||
orderStore.setProjectOrder(updates.projectOrder);
|
||||
}
|
||||
for (const { projectViewKey, order } of updates.workspaceOrders) {
|
||||
orderStore.setWorkspaceOrder(projectViewKey, order);
|
||||
}
|
||||
}, [persistedProjectOrder, projects]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
@@ -193,6 +193,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceSortSupported,
|
||||
workspacePlacements,
|
||||
projects,
|
||||
projectNamesByViewKey,
|
||||
|
||||
@@ -369,9 +369,6 @@ describe("translation resources", () => {
|
||||
|
||||
it("includes Settings expansion keys for the Batch 3A migration", () => {
|
||||
expect(en.settings.diagnostics.title).toBe("Diagnostics");
|
||||
expect(en.settings.diagnostics.legacyTerminalRenderer.label).toBe(
|
||||
"Use legacy terminal renderer",
|
||||
);
|
||||
expect(en.settings.about.title).toBe("About");
|
||||
expect(en.settings.about.releaseChannel.label).toBe("Release channel");
|
||||
expect(en.settings.appearance.theme.title).toBe("Theme");
|
||||
|
||||
@@ -615,6 +615,9 @@ export const ar: TranslationResources = {
|
||||
hostDisconnected: "Host غير متصل",
|
||||
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
|
||||
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
|
||||
fileUploaded: "تم رفع الملف",
|
||||
pasteFileFailed: "تعذر لصق الملف: {{reason}}",
|
||||
uploadingFile: "جارٍ رفع الملف…",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "فتح كعلامة تبويب",
|
||||
@@ -1163,6 +1166,12 @@ export const ar: TranslationResources = {
|
||||
showBackground: "إظهار مساحات العمل في الخلفية",
|
||||
trigger: "تفضيلات العرض",
|
||||
heading: "العرض",
|
||||
sort: {
|
||||
label: "الترتيب",
|
||||
created: "تاريخ الإنشاء",
|
||||
lastPrompt: "آخر طلب",
|
||||
updateHost: "حدّث المضيفين لترتيب مساحات العمل",
|
||||
},
|
||||
grouping: {
|
||||
label: "التجميع",
|
||||
project: "المشروع",
|
||||
@@ -2329,11 +2338,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "التشخيص",
|
||||
legacyTerminalRenderer: {
|
||||
label: "استخدام عارض الطرفية السابق",
|
||||
description: "استخدام طرفية WebView السابقة بعد إعادة فتح الطرفية",
|
||||
accessibilityLabel: "استخدام عارض الطرفية السابق",
|
||||
},
|
||||
testAudio: "اختبار الصوت",
|
||||
playTest: "لعب الاختبار",
|
||||
playing: "جارٍ اللعب...",
|
||||
|
||||
@@ -612,6 +612,9 @@ export const en = {
|
||||
hostDisconnected: "Host is not connected",
|
||||
updateHost: "Update the host to use the native terminal renderer.",
|
||||
unableToSubscribe: "Unable to subscribe to terminal",
|
||||
fileUploaded: "File uploaded",
|
||||
pasteFileFailed: "Failed to paste file: {{reason}}",
|
||||
uploadingFile: "Uploading file…",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Open as tab",
|
||||
@@ -1171,6 +1174,12 @@ export const en = {
|
||||
showBackground: "Show background",
|
||||
trigger: "Display preferences",
|
||||
heading: "Display",
|
||||
sort: {
|
||||
label: "Sort",
|
||||
created: "Created",
|
||||
lastPrompt: "Last prompt",
|
||||
updateHost: "Update hosts to sort workspaces",
|
||||
},
|
||||
grouping: {
|
||||
label: "Grouping",
|
||||
project: "Project",
|
||||
@@ -2417,11 +2426,6 @@ export const en = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "Diagnostics",
|
||||
legacyTerminalRenderer: {
|
||||
label: "Use legacy terminal renderer",
|
||||
description: "Use the previous WebView terminal after reopening a terminal",
|
||||
accessibilityLabel: "Use legacy terminal renderer",
|
||||
},
|
||||
testAudio: "Test audio",
|
||||
playTest: "Play test",
|
||||
playing: "Playing...",
|
||||
|
||||
@@ -621,6 +621,9 @@ export const es: TranslationResources = {
|
||||
hostDisconnected: "Hostno está conectado",
|
||||
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.",
|
||||
unableToSubscribe: "No se puede suscribir al terminal",
|
||||
fileUploaded: "Archivo subido",
|
||||
pasteFileFailed: "No se pudo pegar el archivo: {{reason}}",
|
||||
uploadingFile: "Subiendo archivo…",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Abrir como pestaña",
|
||||
@@ -1199,6 +1202,12 @@ export const es: TranslationResources = {
|
||||
showBackground: "Mostrar espacios en segundo plano",
|
||||
trigger: "Preferencias de visualización",
|
||||
heading: "Visualización",
|
||||
sort: {
|
||||
label: "Orden",
|
||||
created: "Creación",
|
||||
lastPrompt: "Último prompt",
|
||||
updateHost: "Actualiza los hosts para ordenar los espacios de trabajo",
|
||||
},
|
||||
grouping: {
|
||||
label: "Agrupación",
|
||||
project: "Proyecto",
|
||||
@@ -2345,11 +2354,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "Diagnóstico",
|
||||
legacyTerminalRenderer: {
|
||||
label: "Usar el renderizador de terminal anterior",
|
||||
description: "Usa el terminal WebView anterior al volver a abrir un terminal",
|
||||
accessibilityLabel: "Usar el renderizador de terminal anterior",
|
||||
},
|
||||
testAudio: "audio de prueba",
|
||||
playTest: "Prueba de juego",
|
||||
playing: "Jugando...",
|
||||
|
||||
@@ -621,6 +621,9 @@ export const fr: TranslationResources = {
|
||||
hostDisconnected: "L’hôte n’est pas connecté",
|
||||
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.",
|
||||
unableToSubscribe: "Impossible de s’abonner au terminal",
|
||||
fileUploaded: "Fichier envoyé",
|
||||
pasteFileFailed: "Impossible de coller le fichier : {{reason}}",
|
||||
uploadingFile: "Envoi du fichier…",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Ouvrir dans un onglet",
|
||||
@@ -1192,6 +1195,12 @@ export const fr: TranslationResources = {
|
||||
showBackground: "Afficher les espaces en arrière-plan",
|
||||
trigger: "Préférences d’affichage",
|
||||
heading: "Affichage",
|
||||
sort: {
|
||||
label: "Tri",
|
||||
created: "Création",
|
||||
lastPrompt: "Dernier prompt",
|
||||
updateHost: "Mettez les hôtes à jour pour trier les espaces de travail",
|
||||
},
|
||||
grouping: {
|
||||
label: "Regroupement",
|
||||
project: "Projet",
|
||||
@@ -2343,11 +2352,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "Diagnostic",
|
||||
legacyTerminalRenderer: {
|
||||
label: "Utiliser l’ancien rendu du terminal",
|
||||
description: "Utilise l’ancien terminal WebView après la réouverture d’un terminal",
|
||||
accessibilityLabel: "Utiliser l’ancien rendu du terminal",
|
||||
},
|
||||
testAudio: "Tester le son",
|
||||
playTest: "Lancer le test",
|
||||
playing: "Lecture…",
|
||||
|
||||
@@ -621,6 +621,9 @@ export const ja: TranslationResources = {
|
||||
hostDisconnected: "ホストが接続されていません",
|
||||
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
|
||||
unableToSubscribe: "ターミナルに接続できません",
|
||||
fileUploaded: "ファイルをアップロードしました",
|
||||
pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}",
|
||||
uploadingFile: "ファイルをアップロード中…",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "タブで開く",
|
||||
@@ -1176,6 +1179,12 @@ export const ja: TranslationResources = {
|
||||
showBackground: "バックグラウンドを表示",
|
||||
trigger: "表示設定",
|
||||
heading: "表示",
|
||||
sort: {
|
||||
label: "並び順",
|
||||
created: "作成日時",
|
||||
lastPrompt: "最後のプロンプト",
|
||||
updateHost: "ワークスペースを並べ替えるにはホストを更新してください",
|
||||
},
|
||||
grouping: {
|
||||
label: "グループ化",
|
||||
project: "プロジェクト",
|
||||
@@ -2313,11 +2322,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "診断",
|
||||
legacyTerminalRenderer: {
|
||||
label: "以前のターミナルレンダラーを使用",
|
||||
description: "ターミナルを開き直した後、以前の WebView ターミナルを使用します",
|
||||
accessibilityLabel: "以前のターミナルレンダラーを使用",
|
||||
},
|
||||
testAudio: "音声をテスト",
|
||||
playTest: "テスト再生",
|
||||
playing: "再生中...",
|
||||
|
||||
@@ -616,6 +616,9 @@ export const ko: TranslationResources = {
|
||||
hostDisconnected: "호스트가 연결되어 있지 않습니다",
|
||||
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
|
||||
unableToSubscribe: "터미널을 구독할 수 없습니다",
|
||||
fileUploaded: "파일을 업로드했습니다",
|
||||
pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}",
|
||||
uploadingFile: "파일 업로드 중…",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "탭으로 열기",
|
||||
@@ -1170,6 +1173,12 @@ export const ko: TranslationResources = {
|
||||
showBackground: "백그라운드 표시",
|
||||
trigger: "표시 설정",
|
||||
heading: "표시",
|
||||
sort: {
|
||||
label: "정렬",
|
||||
created: "생성일",
|
||||
lastPrompt: "마지막 프롬프트",
|
||||
updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요",
|
||||
},
|
||||
grouping: {
|
||||
label: "그룹화",
|
||||
project: "프로젝트",
|
||||
@@ -2304,11 +2313,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "진단",
|
||||
legacyTerminalRenderer: {
|
||||
label: "레거시 터미널 렌더러 사용",
|
||||
description: "터미널을 다시 열 때 이전 WebView 터미널을 사용합니다.",
|
||||
accessibilityLabel: "레거시 터미널 렌더러 사용",
|
||||
},
|
||||
testAudio: "오디오 테스트",
|
||||
playTest: "테스트 재생",
|
||||
playing: "재생 중...",
|
||||
|
||||
@@ -620,6 +620,9 @@ export const ptBR: TranslationResources = {
|
||||
hostDisconnected: "Host não está conectado",
|
||||
updateHost: "Atualize o host para usar o renderizador de terminal nativo.",
|
||||
unableToSubscribe: "Não foi possível assinar o terminal",
|
||||
fileUploaded: "Arquivo enviado",
|
||||
pasteFileFailed: "Falha ao colar o arquivo: {{reason}}",
|
||||
uploadingFile: "Enviando arquivo…",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Abrir como aba",
|
||||
@@ -1189,6 +1192,12 @@ export const ptBR: TranslationResources = {
|
||||
showBackground: "Mostrar espaços em segundo plano",
|
||||
trigger: "Preferências de exibição",
|
||||
heading: "Exibição",
|
||||
sort: {
|
||||
label: "Ordenação",
|
||||
created: "Criação",
|
||||
lastPrompt: "Último prompt",
|
||||
updateHost: "Atualize os hosts para ordenar os espaços de trabalho",
|
||||
},
|
||||
grouping: {
|
||||
label: "Agrupamento",
|
||||
project: "Projeto",
|
||||
@@ -2328,11 +2337,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "Diagnósticos",
|
||||
legacyTerminalRenderer: {
|
||||
label: "Usar renderizador de terminal anterior",
|
||||
description: "Usa o terminal WebView anterior após reabrir um terminal",
|
||||
accessibilityLabel: "Usar renderizador de terminal anterior",
|
||||
},
|
||||
testAudio: "Testar áudio",
|
||||
playTest: "Reproduzir teste",
|
||||
playing: "Reproduzindo...",
|
||||
|
||||
@@ -622,6 +622,9 @@ export const ru: TranslationResources = {
|
||||
hostDisconnected: "Хост не подключён",
|
||||
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
|
||||
unableToSubscribe: "Не удалось подключиться к терминалу",
|
||||
fileUploaded: "Файл загружен",
|
||||
pasteFileFailed: "Не удалось вставить файл: {{reason}}",
|
||||
uploadingFile: "Загрузка файла…",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Открыть во вкладке",
|
||||
@@ -1180,6 +1183,12 @@ export const ru: TranslationResources = {
|
||||
showBackground: "Показать фоновые пространства",
|
||||
trigger: "Настройки отображения",
|
||||
heading: "Отображение",
|
||||
sort: {
|
||||
label: "Сортировка",
|
||||
created: "Дата создания",
|
||||
lastPrompt: "Последний запрос",
|
||||
updateHost: "Обновите хосты для сортировки рабочих пространств",
|
||||
},
|
||||
grouping: {
|
||||
label: "Группировка",
|
||||
project: "Проект",
|
||||
@@ -2330,11 +2339,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "Диагностика",
|
||||
legacyTerminalRenderer: {
|
||||
label: "Использовать прежний рендерер терминала",
|
||||
description: "Использовать прежний WebView-терминал после повторного открытия",
|
||||
accessibilityLabel: "Использовать прежний рендерер терминала",
|
||||
},
|
||||
testAudio: "Проверка звука",
|
||||
playTest: "Воспроизвести тестовый звук",
|
||||
playing: "Воспроизведение...",
|
||||
|
||||
@@ -615,6 +615,9 @@ export const zhCN: TranslationResources = {
|
||||
hostDisconnected: "Host 未连接",
|
||||
updateHost: "请更新主机以使用原生终端渲染器。",
|
||||
unableToSubscribe: "无法订阅 Terminal",
|
||||
fileUploaded: "文件已上传",
|
||||
pasteFileFailed: "无法粘贴文件:{{reason}}",
|
||||
uploadingFile: "正在上传文件…",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "作为标签页打开",
|
||||
@@ -1155,6 +1158,12 @@ export const zhCN: TranslationResources = {
|
||||
showBackground: "显示后台工作区",
|
||||
trigger: "显示偏好",
|
||||
heading: "显示",
|
||||
sort: {
|
||||
label: "排序",
|
||||
created: "创建时间",
|
||||
lastPrompt: "最近提示词",
|
||||
updateHost: "请更新主机以排序工作区",
|
||||
},
|
||||
grouping: {
|
||||
label: "分组",
|
||||
project: "项目",
|
||||
@@ -2268,11 +2277,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "诊断",
|
||||
legacyTerminalRenderer: {
|
||||
label: "使用旧版终端渲染器",
|
||||
description: "重新打开终端后使用之前的 WebView 终端",
|
||||
accessibilityLabel: "使用旧版终端渲染器",
|
||||
},
|
||||
testAudio: "测试音频",
|
||||
playTest: "播放测试",
|
||||
playing: "正在播放...",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { waitFor } from "@testing-library/dom";
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const noop = () => {};
|
||||
const toastApi = { show: noop, error: noop, copied: noop };
|
||||
const toastApi = { show: () => noop, error: noop, copied: noop };
|
||||
const roots: ReturnType<typeof createRoot>[] = [];
|
||||
function render(children: ReactNode) {
|
||||
const container = document.createElement("div");
|
||||
@@ -124,6 +124,8 @@ beforeEach(async () => {
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
|
||||
|
||||
const workspace: WorkspaceDescriptor = {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Paseo",
|
||||
|
||||
@@ -20,6 +20,8 @@ function project(input: {
|
||||
|
||||
function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id,
|
||||
projectId,
|
||||
projectDisplayName: "acme/app",
|
||||
@@ -174,3 +176,51 @@ describe("buildWorkspaceStructureProjects", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("sorts by creation or human prompt without changing picker name order", () => {
|
||||
const old = {
|
||||
...workspace("old", "p", "/p"),
|
||||
name: "A",
|
||||
createdAt: new Date("2026-01-01"),
|
||||
lastUserMessageAt: new Date("2026-03-01"),
|
||||
};
|
||||
const fresh = {
|
||||
...workspace("fresh", "p", "/p"),
|
||||
name: "B",
|
||||
createdAt: new Date("2026-02-01"),
|
||||
lastUserMessageAt: null,
|
||||
};
|
||||
const sessions = [
|
||||
{
|
||||
serverId: "host",
|
||||
projects: [project({ id: "p", key: "p", root: "/p" })],
|
||||
workspaces: [old, fresh],
|
||||
},
|
||||
];
|
||||
expect(
|
||||
buildWorkspaceStructureProjects({ sessions, workspaceSort: "created" })[0].workspaceKeys,
|
||||
).toEqual(["host:fresh", "host:old"]);
|
||||
expect(
|
||||
buildWorkspaceStructureProjects({ sessions, workspaceSort: "lastPrompt" })[0].workspaceKeys,
|
||||
).toEqual(["host:old", "host:fresh"]);
|
||||
expect(buildWorkspaceStructureProjects({ sessions })[0].workspaceKeys).toEqual([
|
||||
"host:old",
|
||||
"host:fresh",
|
||||
]);
|
||||
});
|
||||
|
||||
test("time ties use workspace identity so renaming cannot move Created rows", () => {
|
||||
const a = { ...workspace("a", "p", "/p"), name: "Z", createdAt: new Date("2026-01-01") };
|
||||
const b = { ...workspace("b", "p", "/p"), name: "A", createdAt: a.createdAt };
|
||||
const sessions = [
|
||||
{ serverId: "h", projects: [project({ id: "p", key: "p", root: "/p" })], workspaces: [b, a] },
|
||||
];
|
||||
expect(
|
||||
buildWorkspaceStructureProjects({ sessions, workspaceSort: "created" })[0].workspaceKeys,
|
||||
).toEqual(["h:a", "h:b"]);
|
||||
a.name = "A";
|
||||
b.name = "Z";
|
||||
expect(
|
||||
buildWorkspaceStructureProjects({ sessions, workspaceSort: "created" })[0].workspaceKeys,
|
||||
).toEqual(["h:a", "h:b"]);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,14 @@ interface WorkspaceStructureSession {
|
||||
workspaces: Iterable<WorkspaceDescriptor>;
|
||||
}
|
||||
|
||||
export type WorkspaceSort = "created" | "lastPrompt";
|
||||
|
||||
interface WorkspaceSortItem {
|
||||
workspaceName: string;
|
||||
workspaceKey: string;
|
||||
sortTime: number;
|
||||
}
|
||||
|
||||
interface ProjectDraft {
|
||||
viewKey: string;
|
||||
projectKey: string | null;
|
||||
@@ -38,12 +46,13 @@ interface ProjectDraft {
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
hosts: Map<string, WorkspaceStructureHostPlacement>;
|
||||
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
|
||||
workspaces: WorkspaceSortItem[];
|
||||
}
|
||||
|
||||
/** The single app boundary that turns host-local projects into grouped display projects. */
|
||||
export function buildWorkspaceStructureProjects(input: {
|
||||
sessions: WorkspaceStructureSession[];
|
||||
workspaceSort?: WorkspaceSort;
|
||||
}): WorkspaceStructureProject[] {
|
||||
const byProject = new Map<string, ProjectDraft>();
|
||||
const projectEntries: Array<{ serverId: string; project: ProjectDescriptor }> = [];
|
||||
@@ -84,7 +93,7 @@ export function buildWorkspaceStructureProjects(input: {
|
||||
const viewKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId);
|
||||
if (!viewKey) continue;
|
||||
byProject.get(viewKey)?.workspaces.push({
|
||||
workspaceId: workspace.id,
|
||||
sortTime: workspaceSortTime(workspace, input.workspaceSort),
|
||||
workspaceName: workspace.name,
|
||||
workspaceKey: `${session.serverId}:${workspace.id}`,
|
||||
});
|
||||
@@ -100,7 +109,7 @@ export function buildWorkspaceStructureProjects(input: {
|
||||
iconWorkingDir: draft.iconWorkingDir,
|
||||
hosts: Array.from(draft.hosts.values()),
|
||||
workspaceKeys: draft.workspaces
|
||||
.sort(compareWorkspaceStructureItems)
|
||||
.sort(input.workspaceSort ? compareWorkspaceTimes : compareWorkspaceStructureItems)
|
||||
.map((workspace) => workspace.workspaceKey),
|
||||
}))
|
||||
.sort(
|
||||
@@ -196,14 +205,27 @@ function getOrCreate<K, V>(map: Map<K, V>, key: K, create: () => V): V {
|
||||
return value;
|
||||
}
|
||||
|
||||
function compareWorkspaceStructureItems(
|
||||
left: { workspaceId: string; workspaceName: string },
|
||||
right: { workspaceId: string; workspaceName: string },
|
||||
): number {
|
||||
function compareWorkspaceStructureItems(left: WorkspaceSortItem, right: WorkspaceSortItem): number {
|
||||
return (
|
||||
left.workspaceName.localeCompare(right.workspaceName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
}) || left.workspaceId.localeCompare(right.workspaceId, undefined, { sensitivity: "base" })
|
||||
}) || left.workspaceKey.localeCompare(right.workspaceKey)
|
||||
);
|
||||
}
|
||||
|
||||
function workspaceSortTime(
|
||||
workspace: WorkspaceDescriptor,
|
||||
sort: WorkspaceSort | undefined,
|
||||
): number {
|
||||
if (!sort) return 0;
|
||||
const time =
|
||||
sort === "lastPrompt"
|
||||
? (workspace.lastUserMessageAt ?? workspace.createdAt)
|
||||
: workspace.createdAt;
|
||||
return time?.getTime() ?? 0;
|
||||
}
|
||||
|
||||
function compareWorkspaceTimes(left: WorkspaceSortItem, right: WorkspaceSortItem): number {
|
||||
return right.sortTime - left.sortTime || left.workspaceKey.localeCompare(right.workspaceKey);
|
||||
}
|
||||
|
||||
@@ -1550,7 +1550,14 @@ it("fills every cached workspace beneath live updates received during the SQLite
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: { ...workspaces[0], name: "Live", activityAt: null, statusEnteredAt: null },
|
||||
workspace: {
|
||||
...workspaces[0],
|
||||
createdAt: undefined,
|
||||
lastUserMessageAt: null,
|
||||
name: "Live",
|
||||
activityAt: null,
|
||||
statusEnteredAt: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ class FakeDaemonClient {
|
||||
supportsBackgroundWorkspaces(): boolean {
|
||||
return false;
|
||||
}
|
||||
relaySecurityFloor: "legacy" | "protected" | undefined = undefined;
|
||||
private state: ConnectionState = { status: "idle" };
|
||||
private listeners = new Set<(status: ConnectionState) => void>();
|
||||
private error: string | null = null;
|
||||
@@ -345,6 +346,11 @@ class Deferred<T> {
|
||||
}
|
||||
}
|
||||
|
||||
function relayPinOf(host: HostProfile | undefined): "protected" | undefined {
|
||||
const connection = host?.connections[0];
|
||||
return connection?.type === "relay" ? connection.minimumSecurityMode : undefined;
|
||||
}
|
||||
|
||||
async function waitForHostOnline(store: HostRuntimeStore, serverId: string): Promise<void> {
|
||||
if (store.getSnapshot(serverId)?.connectionStatus === "online") return;
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -564,6 +570,7 @@ function createPairingStore(
|
||||
connectAttempts?: string[];
|
||||
serverIdForHost?: (host: HostProfile) => string;
|
||||
storage?: HostRuntimeStorage;
|
||||
probeSecurityFloor?: "legacy" | "protected";
|
||||
} = {},
|
||||
): HostRuntimeStore {
|
||||
return new HostRuntimeStore({
|
||||
@@ -575,8 +582,10 @@ function createPairingStore(
|
||||
if (input.password && host.password !== input.password) {
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
}
|
||||
const client = makeConnectedProbeClient(5);
|
||||
client.relaySecurityFloor = input.probeSecurityFloor;
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
client: client as unknown as DaemonClient,
|
||||
serverId: input.serverIdForHost?.(host) ?? host.serverId,
|
||||
hostname: input.hostname ?? "paired",
|
||||
};
|
||||
@@ -787,6 +796,85 @@ describe("HostRuntimeController", () => {
|
||||
expect(controller.getSnapshot().client).toBe(createdClients[1]?.client);
|
||||
});
|
||||
|
||||
it("keeps the active relay client when its security pin is learned", async () => {
|
||||
const relay: HostConnection = {
|
||||
id: "relay:wss:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
daemonPublicKeyB64: "pk_a",
|
||||
};
|
||||
const createdClients: FakeDaemonClient[] = [];
|
||||
const controller = new HostRuntimeController({
|
||||
host: makeHost({ connections: [relay], preferredConnectionId: relay.id }),
|
||||
deps: {
|
||||
createClient: () => {
|
||||
const client = new FakeDaemonClient();
|
||||
createdClients.push(client);
|
||||
return client as unknown as DaemonClient;
|
||||
},
|
||||
connectToDaemon: async ({ host, connection }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: connection.id,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
await controller.activateConnection({ connectionId: relay.id });
|
||||
// The client negotiated protected itself; the pin it reported is already enforced.
|
||||
createdClients[0]!.relaySecurityFloor = "protected";
|
||||
await controller.updateHost(
|
||||
makeHost({
|
||||
connections: [{ ...relay, minimumSecurityMode: "protected" }],
|
||||
preferredConnectionId: relay.id,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createdClients).toHaveLength(1);
|
||||
expect(createdClients[0]?.isDisposed()).toBe(false);
|
||||
expect(controller.getSnapshot().client).toBe(createdClients[0]);
|
||||
});
|
||||
|
||||
it("replaces the active legacy relay client when a strict offer pins its connection", async () => {
|
||||
const relay: HostConnection = {
|
||||
id: "relay:wss:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
daemonPublicKeyB64: "pk_a",
|
||||
};
|
||||
const createdClients: Array<{ client: FakeDaemonClient; connection: HostConnection }> = [];
|
||||
const controller = new HostRuntimeController({
|
||||
host: makeHost({ connections: [relay], preferredConnectionId: relay.id }),
|
||||
deps: {
|
||||
createClient: ({ connection }) => {
|
||||
const client = new FakeDaemonClient();
|
||||
createdClients.push({ client, connection });
|
||||
return client as unknown as DaemonClient;
|
||||
},
|
||||
connectToDaemon: async ({ host, connection }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: connection.id,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
await controller.activateConnection({ connectionId: relay.id });
|
||||
expect(createdClients[0]?.client.relaySecurityFloor).toBeUndefined();
|
||||
const pinned: HostConnection = { ...relay, minimumSecurityMode: "protected" };
|
||||
await controller.updateHost(
|
||||
makeHost({ connections: [pinned], preferredConnectionId: relay.id }),
|
||||
);
|
||||
|
||||
expect(createdClients.map((entry) => entry.connection)).toEqual([relay, pinned]);
|
||||
expect(createdClients[0]?.client.isDisposed()).toBe(true);
|
||||
expect(controller.getSnapshot().client).toBe(createdClients[1]?.client);
|
||||
});
|
||||
|
||||
it("keeps known hosts in connecting when a created client reports idle during connect", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
@@ -3857,6 +3945,96 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("keeps a strict offer's security pin through both pairing paths and a reload", async () => {
|
||||
const storage = createMemoryHostRuntimeStorage();
|
||||
const store = createPairingStore({ storage });
|
||||
answerHostConfirmations(store, true);
|
||||
const strictOffer = (serverId: string): ConnectionOffer => ({
|
||||
...makeOffer({ serverId, daemonPublicKeyB64: `pk_${serverId}` }),
|
||||
minimumSecurityMode: "protected",
|
||||
});
|
||||
|
||||
await store.importConnectionLink(encodeOfferUrl(strictOffer("srv_import")), "openProject");
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(strictOffer("srv_pair")));
|
||||
|
||||
const pinOf = (hosts: HostProfile[], serverId: string) =>
|
||||
relayPinOf(hosts.find((host) => host.serverId === serverId));
|
||||
expect(pinOf(store.getHosts(), "srv_import")).toBe("protected");
|
||||
expect(pinOf(store.getHosts(), "srv_pair")).toBe("protected");
|
||||
|
||||
await waitForHostOnline(store, "srv_pair");
|
||||
const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]");
|
||||
expect(pinOf(persisted, "srv_import")).toBe("protected");
|
||||
expect(pinOf(persisted, "srv_pair")).toBe("protected");
|
||||
store.syncHosts([]);
|
||||
|
||||
const reloaded = createPairingStore({ storage });
|
||||
const registryLoaded = onceHostListMatches(reloaded, () => reloaded.isHostRegistryLoaded());
|
||||
reloaded.boot();
|
||||
await registryLoaded;
|
||||
expect(pinOf(reloaded.getHosts(), "srv_import")).toBe("protected");
|
||||
expect(pinOf(reloaded.getHosts(), "srv_pair")).toBe("protected");
|
||||
|
||||
reloaded.syncHosts([]);
|
||||
});
|
||||
|
||||
it("learns the security pin from an adopted probe client and keeps it across a reload", async () => {
|
||||
const relay: HostConnection = {
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
daemonPublicKeyB64: "pk_saved",
|
||||
};
|
||||
const host = makeHost({
|
||||
serverId: "srv_saved",
|
||||
connections: [relay],
|
||||
preferredConnectionId: relay.id,
|
||||
});
|
||||
const storage = createMemoryHostRuntimeStorage({
|
||||
"@paseo:daemon-registry": JSON.stringify([host]),
|
||||
"@paseo:e2e": "1",
|
||||
});
|
||||
const store = createPairingStore({ storage, probeSecurityFloor: "protected" });
|
||||
|
||||
store.boot();
|
||||
await waitForHostOnline(store, "srv_saved");
|
||||
const pinned = onceHostListMatches(
|
||||
store,
|
||||
() => relayPinOf(store.getHosts()[0]) === "protected",
|
||||
);
|
||||
await pinned;
|
||||
|
||||
const activeClient = store.getSnapshot("srv_saved")?.client as unknown as FakeDaemonClient;
|
||||
expect(activeClient.relaySecurityFloor).toBe("protected");
|
||||
expect(activeClient.isDisposed()).toBe(false);
|
||||
const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]");
|
||||
expect(persisted[0]?.connections[0]?.minimumSecurityMode).toBe("protected");
|
||||
store.syncHosts([]);
|
||||
|
||||
const reloaded = createPairingStore({ storage, probeSecurityFloor: "legacy" });
|
||||
const registryLoaded = onceHostListMatches(reloaded, () => reloaded.isHostRegistryLoaded());
|
||||
reloaded.boot();
|
||||
await registryLoaded;
|
||||
expect(relayPinOf(reloaded.getHosts()[0])).toBe("protected");
|
||||
|
||||
reloaded.syncHosts([]);
|
||||
});
|
||||
|
||||
it("learns the security pin for a host paired from an old offer once it goes online", async () => {
|
||||
const storage = createMemoryHostRuntimeStorage();
|
||||
const store = createPairingStore({ storage, probeSecurityFloor: "protected" });
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
|
||||
await waitForHostOnline(store, "srv_offer");
|
||||
await onceHostListMatches(store, () => relayPinOf(store.getHosts()[0]) === "protected");
|
||||
|
||||
const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]");
|
||||
expect(persisted[0]?.connections[0]?.minimumSecurityMode).toBe("protected");
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("probes a pairing link immediately and saves only after admission", async () => {
|
||||
const store = createPairingStore({ password: "correct-password" });
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
connectionFromListen,
|
||||
createRemoteSshHostConnection,
|
||||
normalizeStoredHostProfile,
|
||||
pinRelayProtectedSecurityInProfiles,
|
||||
upsertHostConnectionInProfiles,
|
||||
registryHasConnection,
|
||||
relayConnectionFromOffer,
|
||||
@@ -191,6 +192,12 @@ function hashForLog(value: string): string {
|
||||
return `h_${Math.abs(hash).toString(16)}`;
|
||||
}
|
||||
|
||||
/** A daemon identity that negotiated protected relay security. */
|
||||
export interface RelaySecurityPin {
|
||||
serverId: string;
|
||||
daemonPublicKeyB64: string;
|
||||
}
|
||||
|
||||
export interface HostRuntimeControllerDeps {
|
||||
createClient: (input: {
|
||||
host: HostProfile;
|
||||
@@ -503,6 +510,29 @@ function buildConnectionCandidates(host: HostProfile): ConnectionCandidate[] {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A pin learned from the active client is already enforced by that client, so
|
||||
* it keeps running. A pin that arrived from elsewhere (a strict offer) while
|
||||
* the active client is still legacy needs a client that enforces it.
|
||||
*/
|
||||
function connectionNeedsNewClient(
|
||||
previous: HostConnection,
|
||||
next: HostConnection,
|
||||
activeClient: DaemonClient | null,
|
||||
): boolean {
|
||||
if (previous.type === "relay" && next.type === "relay") {
|
||||
const { minimumSecurityMode: previousPin, ...previousRest } = previous;
|
||||
const { minimumSecurityMode: nextPin, ...nextRest } = next;
|
||||
if (!equal(previousRest, nextRest)) return true;
|
||||
return (
|
||||
nextPin === "protected" &&
|
||||
previousPin !== "protected" &&
|
||||
activeClient?.relaySecurityFloor !== "protected"
|
||||
);
|
||||
}
|
||||
return !equal(previous, next);
|
||||
}
|
||||
|
||||
function findConnectionById(host: HostProfile, connectionId: string | null): HostConnection | null {
|
||||
if (!connectionId) {
|
||||
return null;
|
||||
@@ -616,6 +646,9 @@ function createDefaultDeps(
|
||||
e2ee: {
|
||||
enabled: true,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
...(connection.minimumSecurityMode
|
||||
? { minimumMode: connection.minimumSecurityMode }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -678,6 +711,7 @@ export class HostRuntimeController {
|
||||
private host: HostProfile;
|
||||
private deps: HostRuntimeControllerDeps;
|
||||
private onReconcileServerId: ((oldId: string, newId: string) => void) | null;
|
||||
private onRelayProtectedSecurityNegotiated: ((input: RelaySecurityPin) => void) | null;
|
||||
private connectionMachineState: HostRuntimeConnectionMachineState;
|
||||
private connectionEpoch = 0;
|
||||
private snapshot: HostRuntimeSnapshot;
|
||||
@@ -702,10 +736,12 @@ export class HostRuntimeController {
|
||||
host: HostProfile;
|
||||
deps?: HostRuntimeControllerDeps;
|
||||
onReconcileServerId?: (oldId: string, newId: string) => void;
|
||||
onRelayProtectedSecurityNegotiated?: (input: RelaySecurityPin) => void;
|
||||
}) {
|
||||
this.host = input.host;
|
||||
this.deps = input.deps ?? createDefaultDeps();
|
||||
this.onReconcileServerId = input.onReconcileServerId ?? null;
|
||||
this.onRelayProtectedSecurityNegotiated = input.onRelayProtectedSecurityNegotiated ?? null;
|
||||
this.connectionMachineState = {
|
||||
tag: "booting",
|
||||
};
|
||||
@@ -807,7 +843,12 @@ export class HostRuntimeController {
|
||||
activeConnectionId &&
|
||||
previousActiveConnection &&
|
||||
nextActiveConnection &&
|
||||
(!equal(previousActiveConnection, nextActiveConnection) || passwordChanged)
|
||||
(connectionNeedsNewClient(
|
||||
previousActiveConnection,
|
||||
nextActiveConnection,
|
||||
this.activeClient,
|
||||
) ||
|
||||
passwordChanged)
|
||||
) {
|
||||
this.connectionLastProbedAt.delete(activeConnectionId);
|
||||
await this.switchToConnection({ connectionId: activeConnectionId });
|
||||
@@ -1433,6 +1474,7 @@ export class HostRuntimeController {
|
||||
void failConnection(new DaemonAuthenticationError(client.authFailureReason));
|
||||
return;
|
||||
}
|
||||
if (state.status === "connected") this.learnRelaySecurity(client, connection.id);
|
||||
this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError });
|
||||
this.updateSnapshot({
|
||||
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
||||
@@ -1446,6 +1488,25 @@ export class HostRuntimeController {
|
||||
}
|
||||
}
|
||||
|
||||
// The active client is the one place every connection path meets: clients
|
||||
// created here and probe clients adopted from `connectToDaemon` both land in
|
||||
// `switchToConnection`. A protected handshake observed here becomes the
|
||||
// persisted pin for that daemon identity.
|
||||
private learnRelaySecurity(client: DaemonClient, connectionId: string): void {
|
||||
const connection = findConnectionById(this.host, connectionId);
|
||||
if (
|
||||
connection?.type !== "relay" ||
|
||||
connection.minimumSecurityMode === "protected" ||
|
||||
client.relaySecurityFloor !== "protected"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.onRelayProtectedSecurityNegotiated?.({
|
||||
serverId: this.host.serverId,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
});
|
||||
}
|
||||
|
||||
adoptReconciledServerId(newServerId: string): void {
|
||||
this.host = { ...this.host, serverId: newServerId };
|
||||
this.snapshot = { ...this.snapshot, serverId: newServerId };
|
||||
@@ -1994,39 +2055,6 @@ export class HostRuntimeStore {
|
||||
});
|
||||
}
|
||||
|
||||
private async upsertRelayConnection(input: {
|
||||
serverId: string;
|
||||
relayEndpoint: string;
|
||||
useTls?: boolean;
|
||||
daemonPublicKeyB64: string;
|
||||
label?: string;
|
||||
password?: string;
|
||||
}): Promise<HostProfile> {
|
||||
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
|
||||
const useTls = input.useTls ?? false;
|
||||
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim();
|
||||
if (!daemonPublicKeyB64) {
|
||||
throw new Error("daemonPublicKeyB64 is required");
|
||||
}
|
||||
const explicitUseTls = input.useTls !== undefined;
|
||||
const profile = await this.upsertHostConnection({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
connection: {
|
||||
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
|
||||
type: "relay",
|
||||
relayEndpoint,
|
||||
...(explicitUseTls ? { useTls } : {}),
|
||||
daemonPublicKeyB64,
|
||||
},
|
||||
});
|
||||
if (input.password) {
|
||||
await this.setHostPassword(profile.serverId, input.password);
|
||||
return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile;
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
|
||||
// confirm when it adds a new host or changes a saved host's key, relay, or
|
||||
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
|
||||
@@ -2094,14 +2122,19 @@ export class HostRuntimeStore {
|
||||
password?: string,
|
||||
): Promise<HostProfile> {
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
return this.upsertRelayConnection({
|
||||
if (!connection.daemonPublicKeyB64) {
|
||||
throw new Error("daemonPublicKeyB64 is required");
|
||||
}
|
||||
const profile = await this.upsertHostConnection({
|
||||
serverId: offer.serverId,
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
useTls: connection.useTls,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
label,
|
||||
password,
|
||||
connection,
|
||||
});
|
||||
if (password) {
|
||||
await this.setHostPassword(profile.serverId, password);
|
||||
return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile;
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
private async probeAndUpsertOffer(
|
||||
@@ -2147,6 +2180,13 @@ export class HostRuntimeStore {
|
||||
});
|
||||
}
|
||||
|
||||
private async pinRelayProtectedSecurity(input: RelaySecurityPin): Promise<void> {
|
||||
const next = pinRelayProtectedSecurityInProfiles({ profiles: this.hosts, ...input });
|
||||
if (next === this.hosts) return;
|
||||
this.setHostsAndSync(next);
|
||||
await this.persistHosts();
|
||||
}
|
||||
|
||||
private async updateHost(
|
||||
serverId: string,
|
||||
apply: (host: HostProfile) => HostProfile,
|
||||
@@ -2356,6 +2396,7 @@ export class HostRuntimeStore {
|
||||
host,
|
||||
deps: this.deps,
|
||||
onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId),
|
||||
onRelayProtectedSecurityNegotiated: (pin) => void this.pinRelayProtectedSecurity(pin),
|
||||
});
|
||||
this.controllers.set(host.serverId, controller);
|
||||
useSessionStore.getState().initializeSession(host.serverId, null);
|
||||
|
||||
@@ -306,6 +306,37 @@ async function expectLegacyWorkspaceRefresh({
|
||||
}
|
||||
|
||||
describe("ReplicaCache", () => {
|
||||
it("preserves workspace sorting timestamps across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const baseline = directory();
|
||||
const workspace = normalizeWorkspaceDescriptor({
|
||||
...workspacePayload(),
|
||||
createdAt: "2026-07-18T08:00:00.000Z",
|
||||
lastUserMessageAt: "2026-07-18T09:00:00.000Z",
|
||||
});
|
||||
baseline.workspaces.set(workspace.id, workspace);
|
||||
commitDirectory(writer, SERVER_ID, baseline);
|
||||
await writer.flush();
|
||||
const reader = createCache(storage);
|
||||
expect((await reader.readDirectory(SERVER_ID)).workspaces.get(workspace.id)).toEqual(workspace);
|
||||
expect((await reader.readWorkspace(SERVER_ID, workspace.id))?.workspace).toEqual(workspace);
|
||||
});
|
||||
|
||||
it.each(["createdAt", "lastUserMessageAt"])(
|
||||
"refetches workspaces cached without %s",
|
||||
async (field) => {
|
||||
const cached = await cacheWithoutBackgroundVisibility();
|
||||
const key = `${SERVER_ID}:workspace:workspace-1`;
|
||||
const row = cached.storage.rows.get(key)!;
|
||||
const payload = JSON.parse(row.payload);
|
||||
payload.background = false;
|
||||
delete payload[field];
|
||||
cached.storage.rows.set(key, { ...row, payload: JSON.stringify(payload) });
|
||||
await expectLegacyWorkspaceRefresh(cached);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([true, false, undefined])(
|
||||
"preserves background visibility (%s) when reopening a cached workspace",
|
||||
async (background) => {
|
||||
|
||||
@@ -296,6 +296,9 @@ const WorkspaceGitRuntimeSchema = z
|
||||
.optional();
|
||||
|
||||
const StoredWorkspaceSchema = z.strictObject({
|
||||
// Require both keys: a cursor without these values would preserve the wrong sort on reconnect.
|
||||
createdAt: IsoDateSchema.nullable(),
|
||||
lastUserMessageAt: IsoDateSchema.nullable(),
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
projectDisplayName: z.string(),
|
||||
@@ -688,8 +691,14 @@ function deserializeAgent(serverId: string, stored: StoredAgent): Agent {
|
||||
};
|
||||
}
|
||||
|
||||
function restoreWorkspace(stored: StoredWorkspace): WorkspaceDescriptor {
|
||||
return normalizeWorkspaceDescriptor({ ...stored, createdAt: stored.createdAt ?? undefined });
|
||||
}
|
||||
|
||||
function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace {
|
||||
return {
|
||||
createdAt: workspace.createdAt?.toISOString() ?? null,
|
||||
lastUserMessageAt: workspace.lastUserMessageAt?.toISOString() ?? null,
|
||||
id: workspace.id,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
@@ -856,7 +865,7 @@ function applyDirectoryRow(
|
||||
case "workspace": {
|
||||
const stored = parseStoredPayload(StoredWorkspaceSchema, row.payload);
|
||||
if (stored.id !== row.id) throw new Error("Replica workspace row id mismatch");
|
||||
result.workspaces.set(row.id, normalizeWorkspaceDescriptor(stored));
|
||||
result.workspaces.set(row.id, restoreWorkspace(stored));
|
||||
return;
|
||||
}
|
||||
case "project": {
|
||||
@@ -946,7 +955,7 @@ export class ReplicaCache {
|
||||
try {
|
||||
const stored = parseStoredPayload(StoredWorkspaceSchema, workspaceRow.payload);
|
||||
if (stored.id !== workspaceRow.id) throw new Error("Replica workspace row id mismatch");
|
||||
workspace = normalizeWorkspaceDescriptor(stored);
|
||||
workspace = restoreWorkspace(stored);
|
||||
} catch {
|
||||
await this.deleteInvalidRow(workspaceRow);
|
||||
return undefined;
|
||||
|
||||
@@ -81,7 +81,6 @@ import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-
|
||||
import { EditorSection } from "@/screens/settings/editor-section";
|
||||
import { FilesSection } from "@/screens/settings/files-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { CommunityLinks } from "@/components/community-links";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
@@ -136,7 +135,7 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View model
|
||||
@@ -383,8 +382,6 @@ function GeneralSection({ settings, handleLanguageChange }: GeneralSectionProps)
|
||||
}
|
||||
|
||||
interface DiagnosticsSectionProps {
|
||||
useLegacyTerminalRenderer: boolean;
|
||||
onUseLegacyTerminalRendererChange: (value: boolean) => void;
|
||||
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
|
||||
isPlaybackTestRunning: boolean;
|
||||
playbackTestResult: string | null;
|
||||
@@ -392,8 +389,6 @@ interface DiagnosticsSectionProps {
|
||||
}
|
||||
|
||||
function DiagnosticsSection({
|
||||
useLegacyTerminalRenderer,
|
||||
onUseLegacyTerminalRendererChange,
|
||||
voiceAudioEngine,
|
||||
isPlaybackTestRunning,
|
||||
playbackTestResult,
|
||||
@@ -407,26 +402,6 @@ function DiagnosticsSection({
|
||||
return (
|
||||
<SettingsSection title={t("settings.diagnostics.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
{isNative ? (
|
||||
<View style={settingsStyles.row} testID="legacy-terminal-renderer-row">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.diagnostics.legacyTerminalRenderer.label")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.diagnostics.legacyTerminalRenderer.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={useLegacyTerminalRenderer}
|
||||
onValueChange={onUseLegacyTerminalRendererChange}
|
||||
accessibilityLabel={t(
|
||||
"settings.diagnostics.legacyTerminalRenderer.accessibilityLabel",
|
||||
)}
|
||||
testID="legacy-terminal-renderer-switch"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={settingsStyles.row} testID="app-diagnostic-row">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.app.rowTitle")}</Text>
|
||||
@@ -1159,13 +1134,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleUseLegacyTerminalRendererChange = useCallback(
|
||||
(useLegacyTerminalRenderer: boolean) => {
|
||||
void updateSettings({ useLegacyTerminalRenderer });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handlePlaybackTest = useCallback(async () => {
|
||||
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
||||
return;
|
||||
@@ -1390,8 +1358,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
case "diagnostics":
|
||||
return (
|
||||
<DiagnosticsSection
|
||||
useLegacyTerminalRenderer={settings.useLegacyTerminalRenderer}
|
||||
onUseLegacyTerminalRendererChange={handleUseLegacyTerminalRendererChange}
|
||||
voiceAudioEngine={voiceAudioEngine}
|
||||
isPlaybackTestRunning={isPlaybackTestRunning}
|
||||
playbackTestResult={playbackTestResult}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { resolveWorkspaceRouteState } from "./workspace-route-state";
|
||||
|
||||
function createWorkspaceDescriptor(): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project",
|
||||
|
||||
@@ -14,6 +14,8 @@ import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function createWorkspaceDescriptor(input: Partial<WorkspaceDescriptor> = {}): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: "/repo/main",
|
||||
projectId: "remote:github.com/getpaseo/paseo",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WorkspaceSort } from "@/projects/workspace-structure";
|
||||
import { useMemo } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
selectWorkspaceExists,
|
||||
selectWorkspaceFields,
|
||||
selectWorkspaceKeys,
|
||||
selectWorkspaceOrderByScope,
|
||||
selectWorkspaceStatusesForBadges,
|
||||
workspaceEqualityFns,
|
||||
type WorkspaceStructure,
|
||||
@@ -99,10 +99,13 @@ export function useWorkspaceDirectory(
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure {
|
||||
export function useWorkspaceStructure(
|
||||
serverIds: string[],
|
||||
workspaceSort?: WorkspaceSort,
|
||||
): WorkspaceStructure {
|
||||
const selectProjects = useMemo(
|
||||
() => createWorkspaceStructureProjectsSelector(serverIds),
|
||||
[serverIds],
|
||||
() => createWorkspaceStructureProjectsSelector(serverIds, workspaceSort),
|
||||
[serverIds, workspaceSort],
|
||||
);
|
||||
const projects = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
@@ -114,20 +117,13 @@ export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure {
|
||||
(state) => selectProjectOrder(state),
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
const workspaceOrderByScope = useStoreWithEqualityFn(
|
||||
useSidebarOrderStore,
|
||||
(state) => selectWorkspaceOrderByScope(state),
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
composeWorkspaceStructure({
|
||||
projects,
|
||||
projectOrder,
|
||||
workspaceOrderByScope,
|
||||
}),
|
||||
[projectOrder, projects, workspaceOrderByScope],
|
||||
[projectOrder, projects],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
selectWorkspaceDirectory,
|
||||
selectWorkspaceFields,
|
||||
selectWorkspaceKeys,
|
||||
selectWorkspaceOrderByScope,
|
||||
selectWorkspaceStatusesForBadges,
|
||||
selectWorkspaceStructureProjects,
|
||||
workspaceEqualityFns,
|
||||
@@ -36,6 +35,8 @@ function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
@@ -118,7 +119,6 @@ function trackSelector<S, T>(
|
||||
function emptySidebarOrder(): SidebarOrderSnapshot {
|
||||
return {
|
||||
projectOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -364,7 +364,6 @@ describe("workspace structure composition", () => {
|
||||
return composeWorkspaceStructure({
|
||||
projects: selectWorkspaceStructureProjects(useSessionStore.getState(), [serverId]),
|
||||
projectOrder: selectProjectOrder(sidebar),
|
||||
workspaceOrderByScope: selectWorkspaceOrderByScope(sidebar),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -619,3 +618,25 @@ describe("selectWorkspaceStatusesForBadges", () => {
|
||||
tracked.stop();
|
||||
});
|
||||
});
|
||||
|
||||
it("a prompt changes only its project's structure and output preserves both project identities", () => {
|
||||
const a = { ...createWorkspace({ id: "a", projectId: "p" }), createdAt: new Date("2026-01-01") };
|
||||
const b = { ...createWorkspace({ id: "b", projectId: "p" }), createdAt: new Date("2026-02-01") };
|
||||
const c = { ...createWorkspace({ id: "c", projectId: "q" }), createdAt: new Date("2026-02-01") };
|
||||
const projects = new Map([a, c].map((w) => [w.projectId, projectDescriptorFromTestWorkspace(w)]));
|
||||
const select = createWorkspaceStructureProjectsSelector([SERVER_ID], "lastPrompt");
|
||||
const state = (workspaces: WorkspaceDescriptor[]) => ({
|
||||
sessions: { [SERVER_ID]: { projects, workspaces: new Map(workspaces.map((w) => [w.id, w])) } },
|
||||
});
|
||||
const before = select(state([a, b, c]));
|
||||
const prompted = { ...a, lastUserMessageAt: new Date("2026-03-01") };
|
||||
const after = select(state([prompted, b, c]));
|
||||
expect(after.find((p) => p.viewKey === "p")?.workspaceKeys).toEqual([
|
||||
`${SERVER_ID}:a`,
|
||||
`${SERVER_ID}:b`,
|
||||
]);
|
||||
expect(after.find((p) => p.viewKey === "q")).toBe(before.find((p) => p.viewKey === "q"));
|
||||
const output = select(state([{ ...prompted, status: "running" }, b, c]));
|
||||
expect(output[0]).toBe(after[0]);
|
||||
expect(output[1]).toBe(after[1]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import {
|
||||
buildWorkspaceStructureProjects,
|
||||
type WorkspaceSort,
|
||||
type WorkspaceStructure,
|
||||
type WorkspaceStructureProject,
|
||||
} from "@/projects/workspace-structure";
|
||||
@@ -25,7 +26,6 @@ export interface SessionsSnapshot {
|
||||
|
||||
export interface SidebarOrderSnapshot {
|
||||
projectOrder: string[];
|
||||
workspaceOrderByProject: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const EMPTY_WORKSPACE_KEYS: string[] = [];
|
||||
@@ -154,6 +154,7 @@ export function selectWorkspaceDirectoryServerIds(
|
||||
export function selectWorkspaceStructureProjects(
|
||||
state: SessionsSnapshot,
|
||||
serverIds: readonly string[],
|
||||
workspaceSort?: WorkspaceSort,
|
||||
): WorkspaceStructureProject[] {
|
||||
const sessions: Array<{
|
||||
serverId: string;
|
||||
@@ -179,11 +180,12 @@ export function selectWorkspaceStructureProjects(
|
||||
return EMPTY_WORKSPACE_STRUCTURE.projects;
|
||||
}
|
||||
|
||||
return buildWorkspaceStructureProjects({ sessions });
|
||||
return buildWorkspaceStructureProjects({ sessions, workspaceSort });
|
||||
}
|
||||
|
||||
export function createWorkspaceStructureProjectsSelector(
|
||||
serverIds: readonly string[],
|
||||
workspaceSort?: WorkspaceSort,
|
||||
): (state: SessionsSnapshot) => WorkspaceStructureProject[] {
|
||||
let previousInputs: Array<{
|
||||
workspaces: Map<string, WorkspaceDescriptor> | undefined;
|
||||
@@ -209,7 +211,12 @@ export function createWorkspaceStructureProjectsSelector(
|
||||
}
|
||||
|
||||
previousInputs = inputs;
|
||||
previousProjects = selectWorkspaceStructureProjects(state, serverIds);
|
||||
const next = selectWorkspaceStructureProjects(state, serverIds, workspaceSort);
|
||||
const previousByKey = new Map(previousProjects?.map((project) => [project.viewKey, project]));
|
||||
previousProjects = next.map((project) => {
|
||||
const previous = previousByKey.get(project.viewKey);
|
||||
return previous && equal(previous, project) ? previous : project;
|
||||
});
|
||||
return previousProjects;
|
||||
};
|
||||
}
|
||||
@@ -244,31 +251,16 @@ export function selectProjectOrder(state: SidebarOrderSnapshot): string[] {
|
||||
return state.projectOrder ?? EMPTY_WORKSPACE_KEYS;
|
||||
}
|
||||
|
||||
export function selectWorkspaceOrderByScope(state: SidebarOrderSnapshot): Record<string, string[]> {
|
||||
return state.workspaceOrderByProject ?? {};
|
||||
}
|
||||
|
||||
export function composeWorkspaceStructure(input: {
|
||||
projects: WorkspaceStructureProject[];
|
||||
projectOrder: readonly string[];
|
||||
workspaceOrderByScope: Record<string, readonly string[]>;
|
||||
}): WorkspaceStructure {
|
||||
if (input.projects.length === 0) {
|
||||
return EMPTY_WORKSPACE_STRUCTURE;
|
||||
}
|
||||
|
||||
const orderedProjects = applyStoredOrdering({
|
||||
items: input.projects.map((project) => {
|
||||
const workspaceOrder = input.workspaceOrderByScope[project.viewKey] ?? EMPTY_WORKSPACE_KEYS;
|
||||
return {
|
||||
...project,
|
||||
workspaceKeys: applyStoredOrdering({
|
||||
items: project.workspaceKeys,
|
||||
storedOrder: workspaceOrder,
|
||||
getKey: (workspaceKey) => workspaceKey,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
items: input.projects,
|
||||
storedOrder: input.projectOrder,
|
||||
getKey: (project) => project.viewKey,
|
||||
});
|
||||
|
||||
@@ -64,6 +64,8 @@ function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
|
||||
@@ -103,6 +103,8 @@ export interface Agent {
|
||||
}
|
||||
|
||||
export interface WorkspaceDescriptor {
|
||||
createdAt: Date | null;
|
||||
lastUserMessageAt: Date | null;
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
@@ -139,6 +141,9 @@ export function normalizeWorkspaceDescriptor(
|
||||
? new Date(statusEnteredAtRaw)
|
||||
: null;
|
||||
return {
|
||||
// COMPAT(workspaceSortTimes): added in v0.13.0, remove after 2027-04-22.
|
||||
createdAt: payload.createdAt ? new Date(payload.createdAt) : null,
|
||||
lastUserMessageAt: payload.lastUserMessageAt ? new Date(payload.lastUserMessageAt) : null,
|
||||
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
|
||||
projectId: payload.projectId,
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store";
|
||||
|
||||
describe("migrateSidebarOrderState", () => {
|
||||
it("prefixes legacy per-server workspace order with the source server id", () => {
|
||||
it("discards legacy workspace ordering while retaining project ordering", () => {
|
||||
const migrated = migrateSidebarOrderState({
|
||||
projectOrderByServerId: {
|
||||
"host-a": ["project-a"],
|
||||
@@ -21,15 +21,12 @@ describe("migrateSidebarOrderState", () => {
|
||||
expect(migrated).toEqual({
|
||||
projectOrder: ["project-a"],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: {
|
||||
"project-a": ["host-a:main", "host-a:feature", "host-b:main"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes pinned workspace order", () => {
|
||||
it("deduplicates pinned workspace order without rewriting identities", () => {
|
||||
const migrated = migrateSidebarOrderState({
|
||||
pinnedWorkspaceOrder: [" host-a:one ", "host-a:one", "", "host-b:two"],
|
||||
pinnedWorkspaceOrder: ["host-a:one", "host-a:one", "", "host-b:two"],
|
||||
});
|
||||
|
||||
expect(migrated.pinnedWorkspaceOrder).toEqual(["host-a:one", "host-b:two"]);
|
||||
@@ -46,7 +43,6 @@ describe("sidebar order keys that end in whitespace", () => {
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: [],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,17 +52,6 @@ describe("sidebar order keys that end in whitespace", () => {
|
||||
expect(useSidebarOrderStore.getState().projectOrder).toEqual([PROJECT_KEY]);
|
||||
});
|
||||
|
||||
it("scopes a workspace order under the project key the sidebar indexes by", () => {
|
||||
useSidebarOrderStore.getState().setWorkspaceOrder(PROJECT_KEY, ["srv-1:main"]);
|
||||
|
||||
// use-sidebar-workspaces-list reads workspaceOrderByProject by view key
|
||||
// directly, so the scope has to be stored under that same key.
|
||||
expect(useSidebarOrderStore.getState().workspaceOrderByProject[PROJECT_KEY]).toEqual([
|
||||
"srv-1:main",
|
||||
]);
|
||||
expect(useSidebarOrderStore.getState().getWorkspaceOrder(PROJECT_KEY)).toEqual(["srv-1:main"]);
|
||||
});
|
||||
|
||||
it("settles the sidebar order reconcile instead of rewriting it forever", () => {
|
||||
const projects: SidebarProjectEntry[] = [
|
||||
{
|
||||
@@ -82,7 +67,6 @@ describe("sidebar order keys that end in whitespace", () => {
|
||||
computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder: useSidebarOrderStore.getState().projectOrder,
|
||||
getWorkspaceOrder: (key) => useSidebarOrderStore.getState().getWorkspaceOrder(key),
|
||||
});
|
||||
|
||||
// First pass adds the newly visible project.
|
||||
@@ -95,11 +79,31 @@ describe("sidebar order keys that end in whitespace", () => {
|
||||
expect(runPass().projectOrder).toBeNull();
|
||||
});
|
||||
|
||||
it("still trims keys when migrating persisted state", () => {
|
||||
it("still trims legacy keys when migrating per-host persisted state", () => {
|
||||
const migrated = migrateSidebarOrderState({
|
||||
projectOrder: [" host-a:one ", "host-a:one"],
|
||||
projectOrderByServerId: { host: [" host-a:one ", "host-a:one"] },
|
||||
});
|
||||
|
||||
expect(migrated.projectOrder).toEqual(["host-a:one"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves project and pinned order while discarding ordinary workspace order", () => {
|
||||
expect(
|
||||
migrateSidebarOrderState({
|
||||
projectOrder: ["b", "a"],
|
||||
pinnedWorkspaceOrder: ["h:y", "h:x"],
|
||||
workspaceOrderByProject: { a: ["h:old", "h:new"] },
|
||||
}),
|
||||
).toEqual({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"] });
|
||||
});
|
||||
|
||||
it("preserves whitespace in current project and pin identities during upgrade", () => {
|
||||
expect(
|
||||
migrateSidebarOrderState({
|
||||
projectOrder: ["project "],
|
||||
pinnedWorkspaceOrder: ["host:workspace "],
|
||||
workspaceOrderByProject: {},
|
||||
}),
|
||||
).toEqual({ projectOrder: ["project "], pinnedWorkspaceOrder: ["host:workspace "] });
|
||||
});
|
||||
|
||||
@@ -7,13 +7,10 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora
|
||||
interface SidebarOrderStoreState {
|
||||
projectOrder: string[];
|
||||
pinnedWorkspaceOrder: string[];
|
||||
workspaceOrderByProject: Record<string, string[]>;
|
||||
getProjectOrder: () => string[];
|
||||
setProjectOrder: (keys: string[]) => void;
|
||||
getPinnedWorkspaceOrder: () => string[];
|
||||
setPinnedWorkspaceOrder: (keys: string[]) => void;
|
||||
getWorkspaceOrder: (projectViewKey: string) => string[];
|
||||
setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void;
|
||||
}
|
||||
|
||||
interface SidebarOrderPersistedState {
|
||||
@@ -28,16 +25,12 @@ const StringArrayRecordSchema = z.record(z.string(), z.array(z.string()));
|
||||
const SidebarOrderPersistedStateSchema = z.strictObject({
|
||||
projectOrder: z.array(z.string()).optional(),
|
||||
pinnedWorkspaceOrder: z.array(z.string()).optional(),
|
||||
// COMPAT(automaticWorkspaceOrder): v0.13.0; accept discarded keys until 2027-04-22 so strict parsing preserves projects/pins.
|
||||
workspaceOrderByProject: StringArrayRecordSchema.optional(),
|
||||
projectOrderByServerId: StringArrayRecordSchema.optional(),
|
||||
workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(),
|
||||
});
|
||||
|
||||
interface SidebarWorkspaceOrderScope {
|
||||
serverId: string;
|
||||
projectViewKey: string;
|
||||
}
|
||||
|
||||
// Trims each key. Only for persisted state read at migration time, where a
|
||||
// stray space is an artifact of an older format rather than part of the key.
|
||||
function normalizeKeys(keys: string[]): string[] {
|
||||
@@ -78,46 +71,17 @@ function dedupeKeys(keys: string[]): string[] {
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceOrderByProject(
|
||||
workspaceOrderByProject: Record<string, string[]> | undefined,
|
||||
): Record<string, string[]> {
|
||||
const normalized: Record<string, string[]> = {};
|
||||
for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) {
|
||||
const scope = projectViewKey.trim();
|
||||
if (!scope) continue;
|
||||
normalized[scope] = normalizeKeys(order);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScope | null {
|
||||
const separatorIndex = scopeKey.indexOf("::");
|
||||
if (separatorIndex < 0) return null;
|
||||
const serverId = scopeKey.slice(0, separatorIndex).trim();
|
||||
const projectViewKey = scopeKey.slice(separatorIndex + 2).trim();
|
||||
if (!serverId || !projectViewKey) return null;
|
||||
return { serverId, projectViewKey };
|
||||
}
|
||||
|
||||
function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null {
|
||||
const workspaceKey = rawWorkspaceKey.trim();
|
||||
if (!workspaceKey) return null;
|
||||
const serverPrefix = `${serverId}:`;
|
||||
return workspaceKey.startsWith(serverPrefix) ? workspaceKey : `${serverPrefix}${workspaceKey}`;
|
||||
}
|
||||
|
||||
export function migrateSidebarOrderState(persistedState: unknown): {
|
||||
projectOrder: string[];
|
||||
pinnedWorkspaceOrder: string[];
|
||||
workspaceOrderByProject: Record<string, string[]>;
|
||||
} {
|
||||
const result = SidebarOrderPersistedStateSchema.safeParse(persistedState);
|
||||
if (!result.success) {
|
||||
return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} };
|
||||
return { projectOrder: [], pinnedWorkspaceOrder: [] };
|
||||
}
|
||||
const state: SidebarOrderPersistedState = result.data;
|
||||
|
||||
const projectOrder = normalizeKeys(state.projectOrder ?? []);
|
||||
const projectOrder = dedupeKeys(state.projectOrder ?? []);
|
||||
const seenProjects = new Set(projectOrder);
|
||||
for (const keys of Object.values(state.projectOrderByServerId ?? {})) {
|
||||
for (const key of normalizeKeys(keys)) {
|
||||
@@ -127,26 +91,9 @@ export function migrateSidebarOrderState(persistedState: unknown): {
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceOrderByProject = normalizeWorkspaceOrderByProject(state.workspaceOrderByProject);
|
||||
for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) {
|
||||
const scope = extractWorkspaceOrderScope(scopeKey);
|
||||
if (!scope) continue;
|
||||
const existing = workspaceOrderByProject[scope.projectViewKey] ?? [];
|
||||
const merged = [...existing];
|
||||
const seen = new Set(merged);
|
||||
for (const key of order) {
|
||||
const workspaceKey = normalizeLegacyWorkspaceKey(scope.serverId, key);
|
||||
if (!workspaceKey || seen.has(workspaceKey)) continue;
|
||||
seen.add(workspaceKey);
|
||||
merged.push(workspaceKey);
|
||||
}
|
||||
workspaceOrderByProject[scope.projectViewKey] = merged;
|
||||
}
|
||||
|
||||
return {
|
||||
projectOrder,
|
||||
pinnedWorkspaceOrder: normalizeKeys(state.pinnedWorkspaceOrder ?? []),
|
||||
workspaceOrderByProject,
|
||||
pinnedWorkspaceOrder: dedupeKeys(state.pinnedWorkspaceOrder ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,7 +102,6 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
|
||||
(set, get) => ({
|
||||
projectOrder: [],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
getProjectOrder: () => get().projectOrder,
|
||||
setProjectOrder: (keys) => {
|
||||
set({ projectOrder: dedupeKeys(keys) });
|
||||
@@ -164,19 +110,6 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
|
||||
setPinnedWorkspaceOrder: (keys) => {
|
||||
set({ pinnedWorkspaceOrder: dedupeKeys(keys) });
|
||||
},
|
||||
getWorkspaceOrder: (projectViewKey) => {
|
||||
if (!projectViewKey.trim()) return [];
|
||||
return get().workspaceOrderByProject[projectViewKey] ?? [];
|
||||
},
|
||||
setWorkspaceOrder: (projectViewKey, keys) => {
|
||||
if (!projectViewKey.trim()) return;
|
||||
set((state) => ({
|
||||
workspaceOrderByProject: {
|
||||
...state.workspaceOrderByProject,
|
||||
[projectViewKey]: dedupeKeys(keys),
|
||||
},
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "sidebar-project-workspace-order",
|
||||
@@ -184,9 +117,8 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
|
||||
partialize: (state) => ({
|
||||
projectOrder: state.projectOrder,
|
||||
pinnedWorkspaceOrder: state.pinnedWorkspaceOrder,
|
||||
workspaceOrderByProject: state.workspaceOrderByProject,
|
||||
}),
|
||||
version: 1,
|
||||
version: 2,
|
||||
migrate: migrateSidebarOrderState,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -32,6 +32,7 @@ function createMemoryStorage(entries: Record<string, string | null>): MemoryStor
|
||||
describe("sidebar view store", () => {
|
||||
beforeEach(() => {
|
||||
useSidebarViewStore.setState({
|
||||
workspaceSort: "created",
|
||||
showBackground: false,
|
||||
groupMode: "project",
|
||||
hostFilters: [],
|
||||
@@ -85,6 +86,7 @@ describe("sidebar view store", () => {
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
workspaceSort: "created",
|
||||
showBackground: false,
|
||||
groupMode: "status",
|
||||
hostFilters: [],
|
||||
@@ -100,6 +102,7 @@ describe("sidebar view store", () => {
|
||||
hostFilter: "host-a",
|
||||
}),
|
||||
).toEqual({
|
||||
workspaceSort: "created",
|
||||
showBackground: false,
|
||||
groupMode: "status",
|
||||
hostFilters: ["host-a"],
|
||||
@@ -115,6 +118,7 @@ describe("sidebar view store", () => {
|
||||
hostFilters: ["host-a", "host-b"],
|
||||
}),
|
||||
).toEqual({
|
||||
workspaceSort: "created",
|
||||
showBackground: false,
|
||||
groupMode: "status",
|
||||
hostFilters: ["host-a", "host-b"],
|
||||
@@ -226,6 +230,7 @@ describe("sidebar view store", () => {
|
||||
projectFilters: ["project-a", "project-b"],
|
||||
}),
|
||||
).toEqual({
|
||||
workspaceSort: "created",
|
||||
showBackground: false,
|
||||
groupMode: "project",
|
||||
hostFilters: ["host-a"],
|
||||
@@ -236,6 +241,7 @@ describe("sidebar view store", () => {
|
||||
|
||||
it("never keeps project filters from state the schema rejects", () => {
|
||||
expect(migrateSidebarViewState({ projectFilters: "project-a" })).toEqual({
|
||||
workspaceSort: "created",
|
||||
showBackground: false,
|
||||
groupMode: "project",
|
||||
hostFilters: [],
|
||||
@@ -287,3 +293,10 @@ describe("sidebar view store", () => {
|
||||
expect(storage.reads).toEqual(["sidebar-view"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to Created and persists the Last prompt choice across migration", () => {
|
||||
expect(migrateSidebarViewState({}).workspaceSort).toBe("created");
|
||||
expect(
|
||||
migrateSidebarViewState({ workspaceSort: "lastPrompt", groupMode: "status" }),
|
||||
).toMatchObject({ workspaceSort: "lastPrompt", groupMode: "status" });
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WorkspaceSort } from "@/projects/workspace-structure";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { persist, type StateStorage } from "zustand/middleware";
|
||||
@@ -9,7 +10,7 @@ export type SidebarGroupMode = "project" | "status";
|
||||
|
||||
const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view";
|
||||
const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode";
|
||||
const SIDEBAR_VIEW_STORE_VERSION = 7;
|
||||
const SIDEBAR_VIEW_STORE_VERSION = 8;
|
||||
|
||||
/**
|
||||
* The key standing for "this workspace carries no labels at all".
|
||||
@@ -47,6 +48,7 @@ function toggleFilterEntry(list: readonly string[], key: string): string[] {
|
||||
|
||||
interface SidebarViewStoreState {
|
||||
groupMode: SidebarGroupMode;
|
||||
workspaceSort: WorkspaceSort;
|
||||
showBackground: boolean;
|
||||
// Empty means "all hosts". A non-empty list pins the sidebar to those hosts.
|
||||
hostFilters: string[];
|
||||
@@ -64,6 +66,7 @@ interface SidebarViewStoreState {
|
||||
labelFilter: SidebarLabelFilter;
|
||||
toggleBackground: () => void;
|
||||
setGroupMode: (mode: SidebarGroupMode) => void;
|
||||
setWorkspaceSort: (sort: WorkspaceSort) => void;
|
||||
toggleHostFilter: (serverId: string) => void;
|
||||
clearHostFilters: () => void;
|
||||
toggleProjectFilter: (viewKey: string) => void;
|
||||
@@ -76,6 +79,7 @@ interface SidebarViewStoreState {
|
||||
|
||||
interface SidebarViewPersistedState {
|
||||
groupMode: SidebarGroupMode;
|
||||
workspaceSort: WorkspaceSort;
|
||||
showBackground: boolean;
|
||||
hostFilters: string[];
|
||||
projectFilters: string[];
|
||||
@@ -87,6 +91,7 @@ const SidebarLabelFilterSchema = z.object({
|
||||
labels: z.array(z.string()),
|
||||
});
|
||||
const SidebarViewPersistedStateSchema = z.strictObject({
|
||||
workspaceSort: z.enum(["created", "lastPrompt"]).optional(),
|
||||
showBackground: z.boolean().optional(),
|
||||
groupMode: PersistedSidebarGroupModeSchema.optional(),
|
||||
hostFilters: z.array(z.string()).optional(),
|
||||
@@ -128,6 +133,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer
|
||||
return {
|
||||
showBackground: false,
|
||||
groupMode: "project",
|
||||
workspaceSort: "created",
|
||||
hostFilters: [],
|
||||
projectFilters: [],
|
||||
labelFilter: emptyLabelFilter(),
|
||||
@@ -140,6 +146,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer
|
||||
return {
|
||||
showBackground: false,
|
||||
groupMode: legacyGroupMode,
|
||||
workspaceSort: "created",
|
||||
hostFilters: [],
|
||||
projectFilters: [],
|
||||
labelFilter: emptyLabelFilter(),
|
||||
@@ -149,6 +156,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer
|
||||
return {
|
||||
showBackground: state.showBackground ?? false,
|
||||
groupMode: state.groupMode === "status" ? "status" : "project",
|
||||
workspaceSort: state.workspaceSort ?? "created",
|
||||
hostFilters: readHostFilters(state),
|
||||
projectFilters: state.projectFilters ?? [],
|
||||
labelFilter: state.labelFilter
|
||||
@@ -187,11 +195,13 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
|
||||
(set) => ({
|
||||
showBackground: false,
|
||||
groupMode: "project",
|
||||
workspaceSort: "created",
|
||||
hostFilters: [],
|
||||
projectFilters: [],
|
||||
labelFilter: emptyLabelFilter(),
|
||||
toggleBackground: () => set((state) => ({ showBackground: !state.showBackground })),
|
||||
setGroupMode: (mode) => set({ groupMode: mode }),
|
||||
setWorkspaceSort: (workspaceSort) => set({ workspaceSort }),
|
||||
toggleHostFilter: (serverId) =>
|
||||
set((state) => ({ hostFilters: toggleFilterEntry(state.hostFilters, serverId) })),
|
||||
clearHostFilters: () => set({ hostFilters: [] }),
|
||||
@@ -239,6 +249,7 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
|
||||
partialize: (state) => ({
|
||||
showBackground: state.showBackground ?? false,
|
||||
groupMode: state.groupMode,
|
||||
workspaceSort: state.workspaceSort,
|
||||
hostFilters: state.hostFilters,
|
||||
projectFilters: state.projectFilters,
|
||||
labelFilter: state.labelFilter,
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DesktopHostBridge } from "@/desktop/host";
|
||||
import {
|
||||
extractTerminalDropPaths,
|
||||
isTerminalDragLeaveOutside,
|
||||
isTerminalFileDrag,
|
||||
prepareDroppedPathForTerminal,
|
||||
prepareDroppedPathsForTerminal,
|
||||
} from "./terminal-file-drop";
|
||||
import { routeTerminalDrop } from "./terminal-file-drop";
|
||||
|
||||
function dataTransfer(input: { types?: string[]; files?: File[] }): DataTransfer {
|
||||
return {
|
||||
types: input.types ?? [],
|
||||
files: input.files ?? [],
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
function fakeFile(input: { name: string; legacyPath?: string }): File {
|
||||
const file = { name: input.name } as unknown as File;
|
||||
function fakeFile(input: { name: string; type?: string; legacyPath?: string }): File {
|
||||
const file = { name: input.name, type: input.type ?? "" } as unknown as File;
|
||||
if (input.legacyPath !== undefined) {
|
||||
Object.defineProperty(file, "path", {
|
||||
configurable: true,
|
||||
@@ -26,45 +13,34 @@ function fakeFile(input: { name: string; legacyPath?: string }): File {
|
||||
return file;
|
||||
}
|
||||
|
||||
function makeNode(children: readonly EventTarget[] = []): EventTarget {
|
||||
return {
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
contains: (other: EventTarget | null) => other !== null && children.includes(other),
|
||||
} as unknown as EventTarget;
|
||||
}
|
||||
|
||||
describe("terminal file drop", () => {
|
||||
it("detects file drags", () => {
|
||||
expect(isTerminalFileDrag(dataTransfer({ types: ["Files"] }))).toBe(true);
|
||||
expect(isTerminalFileDrag(dataTransfer({ types: ["text/plain"] }))).toBe(false);
|
||||
expect(isTerminalFileDrag(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps drag highlight active when moving between terminal children", () => {
|
||||
const child = makeNode();
|
||||
const outside = makeNode();
|
||||
const root = makeNode([child]);
|
||||
|
||||
expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: child })).toBe(false);
|
||||
expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: outside })).toBe(true);
|
||||
expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: null })).toBe(true);
|
||||
expect(isTerminalDragLeaveOutside({ currentTarget: null, relatedTarget: child })).toBe(true);
|
||||
});
|
||||
|
||||
it("extracts paths through Electron webUtils", () => {
|
||||
const file = fakeFile({ name: "photo.png" });
|
||||
const getPathForFile = vi.fn(() => "/Users/me/Desktop/photo.png");
|
||||
const bridge: DesktopHostBridge = { webUtils: { getPathForFile } };
|
||||
describe("terminal file drop routing", () => {
|
||||
const notes = fakeFile({ name: "notes.txt", type: "text/plain" });
|
||||
const electron: DesktopHostBridge = {
|
||||
webUtils: { getPathForFile: (file) => `/Users/me/Desktop/${file.name}` },
|
||||
};
|
||||
|
||||
it("pastes the file's own path when the host is the client's machine", () => {
|
||||
expect(
|
||||
extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), bridge),
|
||||
).toEqual(["/Users/me/Desktop/photo.png"]);
|
||||
expect(getPathForFile).toHaveBeenCalledWith(file);
|
||||
routeTerminalDrop({ kind: "web-file", file: notes }, { hostIsLocal: true, bridge: electron }),
|
||||
).toEqual({ kind: "path", path: "/Users/me/Desktop/notes.txt" });
|
||||
});
|
||||
|
||||
it("falls back to legacy Electron file paths", () => {
|
||||
it("uploads the same file when the host is another machine", () => {
|
||||
expect(
|
||||
routeTerminalDrop(
|
||||
{ kind: "web-file", file: notes },
|
||||
{ hostIsLocal: false, bridge: electron },
|
||||
),
|
||||
).toMatchObject({ kind: "upload" });
|
||||
});
|
||||
|
||||
it("uploads browser files, which have no path on any machine", () => {
|
||||
expect(
|
||||
routeTerminalDrop({ kind: "web-file", file: notes }, { hostIsLocal: true, bridge: null }),
|
||||
).toMatchObject({ kind: "upload" });
|
||||
});
|
||||
|
||||
it("falls back to legacy Electron file paths on a local host", () => {
|
||||
const file = fakeFile({ name: "photo.png", legacyPath: "/tmp/legacy-photo.png" });
|
||||
const bridge: DesktopHostBridge = {
|
||||
webUtils: {
|
||||
@@ -74,45 +50,21 @@ describe("terminal file drop", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), bridge),
|
||||
).toEqual(["/tmp/legacy-photo.png"]);
|
||||
expect(routeTerminalDrop({ kind: "web-file", file }, { hostIsLocal: true, bridge })).toEqual({
|
||||
kind: "path",
|
||||
path: "/tmp/legacy-photo.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops browser files that have no filesystem path", () => {
|
||||
const file = fakeFile({ name: "photo.png" });
|
||||
it("routes desktop paths by host locality too", () => {
|
||||
const item = { kind: "desktop-path", path: "/tmp/report.pdf" } as const;
|
||||
|
||||
expect(
|
||||
extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), null),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("prepares POSIX paths with conservative escaping", () => {
|
||||
const bridge: DesktopHostBridge = { platform: "darwin" };
|
||||
|
||||
expect(prepareDroppedPathForTerminal("/tmp/my image.png", bridge)).toBe("'/tmp/my image.png'");
|
||||
expect(prepareDroppedPathForTerminal("/tmp/a$(touch bad).png", bridge)).toBe(
|
||||
"'/tmp/a(touch bad).png'",
|
||||
);
|
||||
expect(prepareDroppedPathForTerminal("/tmp/it's.png", bridge)).toBe("'/tmp/it\\'s.png'");
|
||||
});
|
||||
|
||||
it("prepares Windows paths with space quoting", () => {
|
||||
const bridge: DesktopHostBridge = { platform: "win32" };
|
||||
|
||||
expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo.png", bridge)).toBe(
|
||||
"C:\\Users\\me\\photo.png",
|
||||
);
|
||||
expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo one.png", bridge)).toBe(
|
||||
'"C:\\Users\\me\\photo one.png"',
|
||||
);
|
||||
});
|
||||
|
||||
it("joins multiple dropped paths for one terminal input", () => {
|
||||
const bridge: DesktopHostBridge = { platform: "darwin" };
|
||||
|
||||
expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"], bridge)).toBe(
|
||||
"'/tmp/a.png' '/tmp/b c.png'",
|
||||
);
|
||||
expect(routeTerminalDrop(item, { hostIsLocal: true, bridge: null })).toEqual({
|
||||
kind: "path",
|
||||
path: "/tmp/report.pdf",
|
||||
});
|
||||
expect(routeTerminalDrop(item, { hostIsLocal: false, bridge: null })).toMatchObject({
|
||||
kind: "upload",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { getMimeTypeFromPath } from "@/attachments/file-types";
|
||||
import { readDesktopFileBytes } from "@/attachments/selected-file";
|
||||
import type { DroppedItem } from "@/components/file-drop/types";
|
||||
import type { DesktopHostBridge } from "@/desktop/host";
|
||||
|
||||
const DANGEROUS_NON_WINDOWS_PATH_CHARS = /[`$|&>~#!^*;<]/g;
|
||||
import { readTerminalFile, type TerminalFile } from "../file-paste";
|
||||
|
||||
function getLegacyFilePath(file: File): string | null {
|
||||
const path = Reflect.get(file, "path");
|
||||
return typeof path === "string" && path.length > 0 ? path : null;
|
||||
}
|
||||
|
||||
function getFilePath(file: File, bridge: DesktopHostBridge | null): string | null {
|
||||
/** Where the client's own filesystem holds a dropped file; null for files that are only bytes. */
|
||||
function getClientFilePath(file: File, bridge: DesktopHostBridge | null): string | null {
|
||||
const getPathForFile = bridge?.webUtils?.getPathForFile;
|
||||
if (typeof getPathForFile === "function") {
|
||||
try {
|
||||
@@ -22,87 +25,39 @@ function getFilePath(file: File, bridge: DesktopHostBridge | null): string | nul
|
||||
return getLegacyFilePath(file);
|
||||
}
|
||||
|
||||
export function isTerminalFileDrag(dataTransfer: DataTransfer | null): boolean {
|
||||
return Boolean(dataTransfer && Array.from(dataTransfer.types).includes("Files"));
|
||||
function fileNameFromPath(path: string): string {
|
||||
return path.split(/[/\\]/).findLast((segment) => segment.length > 0) ?? path;
|
||||
}
|
||||
|
||||
interface ContainsTarget {
|
||||
contains: (other: EventTarget | null) => boolean;
|
||||
async function readDesktopFile(path: string): Promise<TerminalFile> {
|
||||
return {
|
||||
fileName: fileNameFromPath(path),
|
||||
mimeType: getMimeTypeFromPath(path),
|
||||
bytes: await readDesktopFileBytes(path),
|
||||
};
|
||||
}
|
||||
|
||||
function asContainsTarget(value: EventTarget | null): ContainsTarget | null {
|
||||
if (!value || typeof (value as Partial<ContainsTarget>).contains !== "function") {
|
||||
return null;
|
||||
/** How a dropped item reaches the terminal. */
|
||||
export type TerminalDropDelivery =
|
||||
| { kind: "path"; path: string }
|
||||
| { kind: "upload"; read: () => Promise<TerminalFile> };
|
||||
|
||||
/**
|
||||
* A dropped file pastes its own path only when the host is the machine the file
|
||||
* sits on. Anywhere else the host cannot read the client's disk, so the bytes are
|
||||
* uploaded and the host path is pasted instead. The file's type plays no part.
|
||||
*/
|
||||
export function routeTerminalDrop(
|
||||
item: DroppedItem,
|
||||
input: { hostIsLocal: boolean; bridge: DesktopHostBridge | null },
|
||||
): TerminalDropDelivery {
|
||||
if (item.kind === "desktop-path") {
|
||||
return input.hostIsLocal
|
||||
? { kind: "path", path: item.path }
|
||||
: { kind: "upload", read: () => readDesktopFile(item.path) };
|
||||
}
|
||||
return value as unknown as ContainsTarget;
|
||||
}
|
||||
|
||||
export function isTerminalDragLeaveOutside(input: {
|
||||
currentTarget: EventTarget | null;
|
||||
relatedTarget: EventTarget | null;
|
||||
}): boolean {
|
||||
const currentTarget = asContainsTarget(input.currentTarget);
|
||||
if (!currentTarget || !input.relatedTarget) {
|
||||
return true;
|
||||
}
|
||||
return !currentTarget.contains(input.relatedTarget);
|
||||
}
|
||||
|
||||
export function extractTerminalDropPaths(
|
||||
dataTransfer: DataTransfer | null,
|
||||
bridge: DesktopHostBridge | null,
|
||||
): string[] {
|
||||
if (!dataTransfer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths: string[] = [];
|
||||
for (const file of Array.from(dataTransfer.files)) {
|
||||
const path = getFilePath(file, bridge);
|
||||
if (path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function escapeNonWindowsPath(path: string): string {
|
||||
let nextPath = path;
|
||||
if (nextPath.includes("\\")) {
|
||||
nextPath = nextPath.replace(/\\/g, "\\\\");
|
||||
}
|
||||
|
||||
nextPath = nextPath.replace(DANGEROUS_NON_WINDOWS_PATH_CHARS, "");
|
||||
|
||||
if (nextPath.includes("'") && nextPath.includes('"')) {
|
||||
return `$'${nextPath.replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
if (nextPath.includes("'")) {
|
||||
return `'${nextPath.replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
return `'${nextPath}'`;
|
||||
}
|
||||
|
||||
function escapeWindowsPath(path: string): string {
|
||||
if (!path.includes(" ")) {
|
||||
return path;
|
||||
}
|
||||
return `"${path}"`;
|
||||
}
|
||||
|
||||
export function prepareDroppedPathForTerminal(
|
||||
path: string,
|
||||
bridge: DesktopHostBridge | null,
|
||||
): string {
|
||||
if (bridge?.platform === "win32") {
|
||||
return escapeWindowsPath(path);
|
||||
}
|
||||
return escapeNonWindowsPath(path);
|
||||
}
|
||||
|
||||
export function prepareDroppedPathsForTerminal(
|
||||
paths: readonly string[],
|
||||
bridge: DesktopHostBridge | null,
|
||||
): string {
|
||||
return paths.map((path) => prepareDroppedPathForTerminal(path, bridge)).join(" ");
|
||||
const clientPath = input.hostIsLocal ? getClientFilePath(item.file, input.bridge) : null;
|
||||
return clientPath
|
||||
? { kind: "path", path: clientPath }
|
||||
: { kind: "upload", read: () => readTerminalFile(item.file) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { prepareDroppedPathsForTerminal, prepareTerminalPath } from "./terminal-path";
|
||||
|
||||
describe("terminal path quoting", () => {
|
||||
it("prepares POSIX paths with conservative escaping", () => {
|
||||
expect(prepareTerminalPath("/tmp/my image.png")).toBe("'/tmp/my image.png'");
|
||||
expect(prepareTerminalPath("/tmp/a$(touch bad).png")).toBe("'/tmp/a(touch bad).png'");
|
||||
expect(prepareTerminalPath("/tmp/it's.png")).toBe("'/tmp/it\\'s.png'");
|
||||
});
|
||||
|
||||
it("prepares Windows paths with space quoting", () => {
|
||||
expect(prepareTerminalPath("C:\\Users\\me\\photo.png")).toBe("C:\\Users\\me\\photo.png");
|
||||
expect(prepareTerminalPath("C:\\Users\\me\\photo one.png")).toBe(
|
||||
'"C:\\Users\\me\\photo one.png"',
|
||||
);
|
||||
expect(prepareTerminalPath("\\\\server\\share\\photo one.png")).toBe(
|
||||
'"\\\\server\\share\\photo one.png"',
|
||||
);
|
||||
});
|
||||
|
||||
it("joins multiple dropped paths for one terminal input", () => {
|
||||
expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"])).toBe(
|
||||
"'/tmp/a.png' '/tmp/b c.png'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// Bundled into the terminal WebView as well as the app: keep this module dependency-free.
|
||||
const DANGEROUS_NON_WINDOWS_PATH_CHARS = /[`$|&>~#!^*;<]/g;
|
||||
|
||||
function escapeNonWindowsPath(path: string): string {
|
||||
let nextPath = path;
|
||||
if (nextPath.includes("\\")) {
|
||||
nextPath = nextPath.replace(/\\/g, "\\\\");
|
||||
}
|
||||
|
||||
nextPath = nextPath.replace(DANGEROUS_NON_WINDOWS_PATH_CHARS, "");
|
||||
|
||||
if (nextPath.includes("'") && nextPath.includes('"')) {
|
||||
return `$'${nextPath.replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
if (nextPath.includes("'")) {
|
||||
return `'${nextPath.replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
return `'${nextPath}'`;
|
||||
}
|
||||
|
||||
function escapeWindowsPath(path: string): string {
|
||||
if (!path.includes(" ")) {
|
||||
return path;
|
||||
}
|
||||
return `"${path}"`;
|
||||
}
|
||||
|
||||
const WINDOWS_PATH = /^(?:[A-Za-z]:[\\/]|\\\\)/;
|
||||
|
||||
/** Quotes a path for the shell of the machine it belongs to, identified by the path's shape. */
|
||||
export function prepareTerminalPath(path: string): string {
|
||||
if (WINDOWS_PATH.test(path)) {
|
||||
return escapeWindowsPath(path);
|
||||
}
|
||||
return escapeNonWindowsPath(path);
|
||||
}
|
||||
|
||||
export function prepareDroppedPathsForTerminal(paths: readonly string[]): string {
|
||||
return paths.map(prepareTerminalPath).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit";
|
||||
import { TerminalFilePasteQueue, type TerminalFile, type TerminalFilePasteTarget } from "./index";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function file(fileName: string, bytes: number[] = [1, 2, 3]): TerminalFile {
|
||||
return { fileName, mimeType: "image/png", bytes: new Uint8Array(bytes) };
|
||||
}
|
||||
|
||||
function settle(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
class FakeHost implements TerminalFilePasteTarget {
|
||||
readonly uploads: TerminalFile[] = [];
|
||||
readonly pasted: string[] = [];
|
||||
private readonly results = new Map<string, Promise<string>>();
|
||||
|
||||
holdUpload(fileName: string): ReturnType<typeof deferred<string>> {
|
||||
const result = deferred<string>();
|
||||
this.results.set(fileName, result.promise);
|
||||
return result;
|
||||
}
|
||||
|
||||
async upload(input: TerminalFile): Promise<string> {
|
||||
this.uploads.push(input);
|
||||
return this.results.get(input.fileName) ?? `/host/uploads/${input.fileName}`;
|
||||
}
|
||||
|
||||
paste(text: string): void {
|
||||
this.pasted.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
function createQueue() {
|
||||
const events = { uploading: [] as boolean[], uploaded: 0, failures: [] as string[] };
|
||||
const queue = new TerminalFilePasteQueue({
|
||||
onUploadingChange: (uploading) => {
|
||||
events.uploading.push(uploading);
|
||||
},
|
||||
onUploaded: () => {
|
||||
events.uploaded += 1;
|
||||
},
|
||||
onFailure: (error) => {
|
||||
events.failures.push(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
});
|
||||
return { queue, events, host: new FakeHost() };
|
||||
}
|
||||
|
||||
describe("terminal file paste queue", () => {
|
||||
it("pastes each uploaded host path in gesture order", async () => {
|
||||
const { queue, host, events } = createQueue();
|
||||
const firstRead = deferred<TerminalFile | null>();
|
||||
const firstUpload = host.holdUpload("a.png");
|
||||
|
||||
queue.add(firstRead.promise, host);
|
||||
queue.add(Promise.resolve(file("b.png")), host);
|
||||
await settle();
|
||||
expect(host.uploads).toEqual([]);
|
||||
|
||||
firstRead.resolve(file("a.png"));
|
||||
await settle();
|
||||
expect(host.uploads.map((upload) => upload.fileName)).toEqual(["a.png"]);
|
||||
|
||||
firstUpload.resolve("/host/uploads/a.png");
|
||||
await settle();
|
||||
expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]);
|
||||
expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] });
|
||||
});
|
||||
|
||||
it("quotes Windows host paths for the host shell", async () => {
|
||||
const { queue, host } = createQueue();
|
||||
host.holdUpload("shot.png").resolve("C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png");
|
||||
|
||||
queue.add(Promise.resolve(file("shot.png")), host);
|
||||
await settle();
|
||||
|
||||
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"']);
|
||||
});
|
||||
|
||||
it("uploads under a file name that needs no shell quoting", async () => {
|
||||
const { queue, host } = createQueue();
|
||||
|
||||
queue.add(Promise.resolve(file("Screen Shot #1 (it's $HOME).png")), host);
|
||||
await settle();
|
||||
|
||||
expect(host.uploads.map((upload) => upload.fileName)).toEqual([
|
||||
"Screen_Shot__1__it_s__HOME_.png",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does nothing for a gesture that carried no file", async () => {
|
||||
const { queue, host, events } = createQueue();
|
||||
|
||||
queue.add(Promise.resolve(null), host);
|
||||
await settle();
|
||||
|
||||
expect(host.uploads).toEqual([]);
|
||||
expect(host.pasted).toEqual([]);
|
||||
expect(events).toEqual({ uploading: [], uploaded: 0, failures: [] });
|
||||
});
|
||||
|
||||
it("reports a failed upload and keeps pasting later files", async () => {
|
||||
const { queue, host, events } = createQueue();
|
||||
host.holdUpload("a.png").reject(new Error("disk full"));
|
||||
|
||||
queue.add(Promise.resolve(file("a.png")), host);
|
||||
queue.add(Promise.resolve(file("b.png")), host);
|
||||
await settle();
|
||||
|
||||
expect(events.failures).toEqual(["disk full"]);
|
||||
expect(events.uploaded).toBe(1);
|
||||
expect(host.pasted).toEqual(["'/host/uploads/b.png'"]);
|
||||
});
|
||||
|
||||
it("reports unreadable and oversized files without uploading them", async () => {
|
||||
const { queue, host, events } = createQueue();
|
||||
const oversized = {
|
||||
...file("big.png"),
|
||||
bytes: new Uint8Array(FILE_UPLOAD_MAX_BYTES + 1),
|
||||
};
|
||||
|
||||
queue.add(Promise.reject(new Error("unreadable")), host);
|
||||
queue.add(Promise.resolve(oversized), host);
|
||||
await settle();
|
||||
|
||||
expect(events.failures).toEqual(["unreadable", "big.png exceeds the upload limit"]);
|
||||
expect(host.uploads).toEqual([]);
|
||||
});
|
||||
|
||||
it("never pastes the path of an upload cancelled mid-flight", async () => {
|
||||
const { queue, host, events } = createQueue();
|
||||
const upload = host.holdUpload("a.png");
|
||||
|
||||
queue.add(Promise.resolve(file("a.png")), host);
|
||||
queue.add(Promise.resolve(file("b.png")), host);
|
||||
await settle();
|
||||
expect(queue.cancel()).toBe(true);
|
||||
expect(events.uploading).toEqual([true, false]);
|
||||
|
||||
upload.resolve("/host/uploads/a.png");
|
||||
await settle();
|
||||
expect(host.pasted).toEqual([]);
|
||||
expect(host.uploads.map((entry) => entry.fileName)).toEqual(["a.png"]);
|
||||
expect(events.failures).toEqual([]);
|
||||
expect(events.uploaded).toBe(0);
|
||||
});
|
||||
|
||||
it("does not hold new pastes behind a cancelled upload that never settles", async () => {
|
||||
const { queue, host, events } = createQueue();
|
||||
host.holdUpload("stuck.png");
|
||||
|
||||
queue.add(Promise.resolve(file("stuck.png")), host);
|
||||
await settle();
|
||||
queue.cancel();
|
||||
queue.add(Promise.resolve(file("next.png")), host);
|
||||
await settle();
|
||||
|
||||
expect(host.pasted).toEqual(["'/host/uploads/next.png'"]);
|
||||
expect(events.uploading).toEqual([true, false, true, false]);
|
||||
expect(queue.cancel()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit";
|
||||
import { prepareTerminalPath } from "../drop/terminal-path";
|
||||
|
||||
/** File bytes from a paste or drop gesture, before they become a file on the host. */
|
||||
export interface TerminalFile {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
/** A file over the upload limit; the pane words it for the user. */
|
||||
export class TerminalFileTooLargeError extends Error {
|
||||
constructor(readonly fileName: string) {
|
||||
super(`${fileName} exceeds the upload limit`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The pane's host and terminal: where a file is stored and where its path is pasted. */
|
||||
export interface TerminalFilePasteTarget {
|
||||
/** Stores the file on the host and returns its host path. */
|
||||
upload(file: TerminalFile): Promise<string>;
|
||||
paste(text: string): void;
|
||||
}
|
||||
|
||||
interface TerminalFilePasteEvents {
|
||||
onUploadingChange(uploading: boolean): void;
|
||||
onUploaded(): void;
|
||||
onFailure(error: unknown): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a pane's file gestures into pasted host paths, one at a time and in
|
||||
* gesture order. Ordinary input never waits on it.
|
||||
*/
|
||||
export class TerminalFilePasteQueue {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
private generation = 0;
|
||||
private pending = 0;
|
||||
private uploading = false;
|
||||
|
||||
constructor(private readonly events: TerminalFilePasteEvents) {}
|
||||
|
||||
/** Queues a gesture. `file` resolves to null when the gesture carried no file. */
|
||||
add(file: Promise<TerminalFile | null>, target: TerminalFilePasteTarget): void {
|
||||
const generation = this.generation;
|
||||
this.pending += 1;
|
||||
this.tail = this.tail.then(() => this.paste(file, target, generation));
|
||||
}
|
||||
|
||||
/** Drops queued and in-flight gestures. Returns whether any were dropped. */
|
||||
cancel(): boolean {
|
||||
const dropped = this.pending > 0;
|
||||
this.generation += 1;
|
||||
this.pending = 0;
|
||||
// A cancelled upload can stay unsettled until its connection resolves.
|
||||
this.tail = Promise.resolve();
|
||||
this.setUploading(false);
|
||||
return dropped;
|
||||
}
|
||||
|
||||
private async paste(
|
||||
read: Promise<TerminalFile | null>,
|
||||
target: TerminalFilePasteTarget,
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
const isCurrent = () => generation === this.generation;
|
||||
try {
|
||||
const file = await read;
|
||||
if (!file || !isCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (file.bytes.byteLength > FILE_UPLOAD_MAX_BYTES) {
|
||||
throw new TerminalFileTooLargeError(file.fileName);
|
||||
}
|
||||
this.setUploading(true);
|
||||
const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) });
|
||||
if (isCurrent()) {
|
||||
target.paste(prepareTerminalPath(path));
|
||||
this.events.onUploaded();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isCurrent()) {
|
||||
this.events.onFailure(error);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
this.pending -= 1;
|
||||
this.setUploading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setUploading(uploading: boolean): void {
|
||||
if (this.uploading === uploading) {
|
||||
return;
|
||||
}
|
||||
this.uploading = uploading;
|
||||
this.events.onUploadingChange(uploading);
|
||||
}
|
||||
}
|
||||
|
||||
// The host path is pasted into a shell or TUI; keep its last segment unquotable.
|
||||
function toShellSafeFileName(fileName: string): string {
|
||||
return fileName.replace(/[^\w.-]/g, "_");
|
||||
}
|
||||
|
||||
export async function readTerminalFile(file: File): Promise<TerminalFile> {
|
||||
return {
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
bytes: new Uint8Array(await file.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
|
||||
export function terminalFileFromBase64(input: {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
data: string;
|
||||
}): TerminalFile {
|
||||
const binary = atob(input.data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return { fileName: input.fileName, mimeType: input.mimeType, bytes };
|
||||
}
|
||||
|
||||
/** File extension for a clipboard image, which arrives as bytes with only a MIME type. */
|
||||
export function imageExtension(mimeType: string): string {
|
||||
const subtype = mimeType.split("/")[1]?.split(/[+;]/)[0] ?? "";
|
||||
if (subtype === "jpeg") {
|
||||
return "jpg";
|
||||
}
|
||||
return subtype || "png";
|
||||
}
|
||||
@@ -4,29 +4,14 @@ import { resolveTerminalRendererSelection } from "./terminal-renderer-capability
|
||||
|
||||
describe("terminal renderer capability", () => {
|
||||
it("requires input mode replay before attaching the native renderer", () => {
|
||||
expect(
|
||||
resolveTerminalRendererSelection({
|
||||
useLegacyTerminalRenderer: false,
|
||||
supportsTerminalInputModeReplay: false,
|
||||
}),
|
||||
).toBe("update-host");
|
||||
expect(resolveTerminalRendererSelection({ supportsTerminalInputModeReplay: false })).toBe(
|
||||
"update-host",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows the native renderer when the daemon advertises input mode replay", () => {
|
||||
expect(
|
||||
resolveTerminalRendererSelection({
|
||||
useLegacyTerminalRenderer: false,
|
||||
supportsTerminalInputModeReplay: true,
|
||||
}),
|
||||
).toBe("native-grid");
|
||||
});
|
||||
|
||||
it("keeps the explicit legacy renderer available with older daemons", () => {
|
||||
expect(
|
||||
resolveTerminalRendererSelection({
|
||||
useLegacyTerminalRenderer: true,
|
||||
supportsTerminalInputModeReplay: false,
|
||||
}),
|
||||
).toBe("webview");
|
||||
expect(resolveTerminalRendererSelection({ supportsTerminalInputModeReplay: true })).toBe(
|
||||
"native-grid",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
export type TerminalRendererSelection = "native-grid" | "webview" | "update-host";
|
||||
export type TerminalRendererSelection = "native-grid" | "update-host";
|
||||
|
||||
export function resolveTerminalRendererSelection(input: {
|
||||
useLegacyTerminalRenderer: boolean;
|
||||
supportsTerminalInputModeReplay: boolean;
|
||||
}): TerminalRendererSelection {
|
||||
if (input.useLegacyTerminalRenderer) {
|
||||
return "webview";
|
||||
}
|
||||
return input.supportsTerminalInputModeReplay ? "native-grid" : "update-host";
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { page, userEvent } from "@vitest/browser/context";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||
import type { TerminalFile } from "../file-paste";
|
||||
import {
|
||||
encodeTerminalOutput,
|
||||
TerminalEmulatorRuntime,
|
||||
type TerminalClipboard,
|
||||
} from "./terminal-emulator-runtime";
|
||||
|
||||
vi.mock("@xterm/addon-webgl", () => ({
|
||||
WebglAddon: class WebglAddon {
|
||||
@@ -41,6 +46,36 @@ interface MountedTerminal {
|
||||
terminalKeys: TerminalKeyRecord[];
|
||||
inputModeChanges: TerminalInputModeState[];
|
||||
openedUrls: string[];
|
||||
clipboard: FakeClipboard;
|
||||
filePastes: Promise<TerminalFile | null>[];
|
||||
}
|
||||
|
||||
class FakeClipboard implements TerminalClipboard {
|
||||
text = "";
|
||||
image: { mimeType: string; bytes: number[] } | null = null;
|
||||
imageReads = 0;
|
||||
|
||||
async readText(): Promise<string> {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
async read(): Promise<readonly Pick<ClipboardItem, "types" | "getType">[]> {
|
||||
this.imageReads += 1;
|
||||
const image = this.image;
|
||||
if (!image) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
types: ["text/html", image.mimeType],
|
||||
getType: async (type: string) => new Blob([new Uint8Array(image.bytes)], { type }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async writeText(text: string): Promise<void> {
|
||||
this.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
const mountedTerminals: MountedTerminal[] = [];
|
||||
@@ -100,8 +135,11 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
|
||||
const terminalKeys: TerminalKeyRecord[] = [];
|
||||
const inputModeChanges: TerminalInputModeState[] = [];
|
||||
const openedUrls: string[] = [];
|
||||
const clipboard = new FakeClipboard();
|
||||
const filePastes: Promise<TerminalFile | null>[] = [];
|
||||
const runtime = new TerminalEmulatorRuntime(
|
||||
input.isMac === undefined ? undefined : { isMac: input.isMac },
|
||||
clipboard,
|
||||
);
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
@@ -120,6 +158,9 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
|
||||
onOpenExternalUrl: (url) => {
|
||||
openedUrls.push(url);
|
||||
},
|
||||
onFilePaste: (file) => {
|
||||
filePastes.push(file);
|
||||
},
|
||||
},
|
||||
});
|
||||
runtime.mount({
|
||||
@@ -143,6 +184,8 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
|
||||
terminalKeys,
|
||||
inputModeChanges,
|
||||
openedUrls,
|
||||
clipboard,
|
||||
filePastes,
|
||||
};
|
||||
mountedTerminals.push(mounted);
|
||||
return mounted;
|
||||
@@ -413,6 +456,78 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
expect(mounted.inputs).toEqual(["legacy renderer paste"]);
|
||||
});
|
||||
|
||||
it("pastes clipboard text on Ctrl+V without reading the clipboard image", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMac: false });
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
mounted.clipboard.text = "clipboard text";
|
||||
mounted.clipboard.image = { mimeType: "image/png", bytes: [1, 2, 3] };
|
||||
|
||||
dispatchTerminalKey({ host: mounted.host, key: "v", ctrlKey: true });
|
||||
|
||||
expect(mounted.filePastes).toHaveLength(1);
|
||||
await expect(mounted.filePastes[0]).resolves.toBeNull();
|
||||
expect(mounted.inputs).toEqual(["clipboard text"]);
|
||||
expect(mounted.clipboard.imageReads).toBe(0);
|
||||
});
|
||||
|
||||
it("hands the clipboard image to onFilePaste on Ctrl+V when there is no text", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMac: false });
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
mounted.clipboard.image = { mimeType: "image/jpeg", bytes: [1, 2, 3] };
|
||||
|
||||
dispatchTerminalKey({ host: mounted.host, key: "v", ctrlKey: true });
|
||||
|
||||
expect(mounted.filePastes).toHaveLength(1);
|
||||
await expect(mounted.filePastes[0]).resolves.toEqual({
|
||||
fileName: "clipboard.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
});
|
||||
expect(mounted.inputs).toEqual([]);
|
||||
});
|
||||
|
||||
it("takes file-only paste events at xterm's input before xterm pastes empty text", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([new Uint8Array([1, 2, 3])], "shot.png", { type: "image/png" }));
|
||||
const event = new ClipboardEvent("paste", {
|
||||
clipboardData: transfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
mounted.host.querySelector(".xterm-helper-textarea")!.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(mounted.filePastes).toHaveLength(1);
|
||||
await expect(mounted.filePastes[0]).resolves.toEqual({
|
||||
fileName: "shot.png",
|
||||
mimeType: "image/png",
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
});
|
||||
await nextFrame();
|
||||
expect(mounted.inputs).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves paste events with text to xterm", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
const transfer = new DataTransfer();
|
||||
transfer.setData("text/plain", "echo hi");
|
||||
transfer.items.add(new File([new Uint8Array([1])], "shot.png", { type: "image/png" }));
|
||||
|
||||
mounted.host
|
||||
.querySelector(".xterm-helper-textarea")!
|
||||
.dispatchEvent(
|
||||
new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }),
|
||||
);
|
||||
|
||||
await waitFor({ predicate: () => mounted.inputs.length > 0 });
|
||||
expect(mounted.inputs).toEqual(["echo hi"]);
|
||||
expect(mounted.filePastes).toEqual([]);
|
||||
});
|
||||
|
||||
it("refreshes visible rows on a forced same-size resize", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { isFindShortcut, type FindShortcutPlatform } from "@/pane-find/find-shortcut";
|
||||
import { isMacUserAgent } from "@/utils/mac-user-agent";
|
||||
import { resolveTerminalFontFamily, resolveTerminalFontSize } from "./terminal-font";
|
||||
import { imageExtension, readTerminalFile, type TerminalFile } from "../file-paste";
|
||||
|
||||
export type TerminalOutputData = Uint8Array;
|
||||
|
||||
@@ -83,6 +84,8 @@ export interface TerminalEmulatorRuntimeCallbacks {
|
||||
disposition: "main" | "side",
|
||||
) => Promise<void> | void;
|
||||
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
||||
/** A paste gesture that can carry a file; resolves to null when it carried none. */
|
||||
onFilePaste?: (file: Promise<TerminalFile | null>) => void;
|
||||
}
|
||||
|
||||
export interface TerminalResizeEvent {
|
||||
@@ -122,6 +125,7 @@ interface TerminalEmulatorRuntimeDisposables {
|
||||
clearFitTimeouts: () => void;
|
||||
removeFontListeners: () => void;
|
||||
removeTouchListeners: () => void;
|
||||
removeFilePasteListener: () => void;
|
||||
restoreDocumentStyles: () => void;
|
||||
restoreViewportStyles: () => void;
|
||||
disposeFitAddon: () => void;
|
||||
@@ -180,8 +184,51 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
|
||||
};
|
||||
}
|
||||
|
||||
export interface TerminalClipboard {
|
||||
readText(): Promise<string>;
|
||||
read(): Promise<readonly Pick<ClipboardItem, "types" | "getType">[]>;
|
||||
writeText(text: string): Promise<void>;
|
||||
}
|
||||
|
||||
const browserClipboard: TerminalClipboard = {
|
||||
readText: () => navigator.clipboard.readText(),
|
||||
read: () => navigator.clipboard.read(),
|
||||
writeText: (text) => navigator.clipboard.writeText(text),
|
||||
};
|
||||
|
||||
async function readClipboardImage(clipboard: TerminalClipboard): Promise<TerminalFile | null> {
|
||||
for (const item of await clipboard.read()) {
|
||||
const mimeType = item.types.find((type) => type.startsWith("image/"));
|
||||
if (mimeType) {
|
||||
const blob = await item.getType(mimeType);
|
||||
return {
|
||||
fileName: `clipboard.${imageExtension(mimeType)}`,
|
||||
mimeType,
|
||||
bytes: new Uint8Array(await blob.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The file of a paste that carries no text. xterm would paste it as empty text. */
|
||||
function findFileOnlyPaste(transfer: DataTransfer | null): File | null {
|
||||
if (!transfer || transfer.getData("text/plain").length > 0) {
|
||||
return null;
|
||||
}
|
||||
for (const item of Array.from(transfer.items)) {
|
||||
if (item.kind === "file") {
|
||||
return item.getAsFile();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class TerminalEmulatorRuntime {
|
||||
constructor(private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() }) {}
|
||||
constructor(
|
||||
private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() },
|
||||
private readonly clipboard: TerminalClipboard = browserClipboard,
|
||||
) {}
|
||||
|
||||
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
|
||||
private pendingModifiers: PendingTerminalModifiers = {
|
||||
@@ -327,19 +374,14 @@ export class TerminalEmulatorRuntime {
|
||||
|
||||
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
|
||||
if (key === "c" && terminal.hasSelection()) {
|
||||
void navigator.clipboard.writeText(terminal.getSelection());
|
||||
void this.clipboard.writeText(terminal.getSelection());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+V: paste from clipboard into terminal
|
||||
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
|
||||
if (key === "v") {
|
||||
event.preventDefault();
|
||||
void navigator.clipboard.readText().then((text) => {
|
||||
if (text) {
|
||||
terminal.paste(text);
|
||||
}
|
||||
return;
|
||||
});
|
||||
this.pasteClipboard(terminal);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -666,6 +708,20 @@ export class TerminalEmulatorRuntime {
|
||||
|
||||
this.attachKeyEventHandler(terminal);
|
||||
|
||||
// Cmd+V and context-menu paste arrive as DOM paste events. Take file-only
|
||||
// pastes before xterm's own listener consumes them as empty text.
|
||||
const filePasteHandler = (event: ClipboardEvent): void => {
|
||||
const onFilePaste = this.callbacks.onFilePaste;
|
||||
const file = findFileOnlyPaste(event.clipboardData);
|
||||
if (!onFilePaste || !file) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onFilePaste(readTerminalFile(file));
|
||||
};
|
||||
input.host.addEventListener("paste", filePasteHandler, true);
|
||||
|
||||
const removeTouchListeners = this.setupTouchScrollHandlers({
|
||||
root: input.root,
|
||||
host: input.host,
|
||||
@@ -755,6 +811,9 @@ export class TerminalEmulatorRuntime {
|
||||
fontSet?.removeEventListener?.("loadingdone", fontReadyHandler);
|
||||
},
|
||||
removeTouchListeners,
|
||||
removeFilePasteListener: () => {
|
||||
input.host.removeEventListener("paste", filePasteHandler, true);
|
||||
},
|
||||
restoreDocumentStyles,
|
||||
restoreViewportStyles,
|
||||
disposeFitAddon: () => {
|
||||
@@ -784,6 +843,7 @@ export class TerminalEmulatorRuntime {
|
||||
disposables.clearFitTimeouts();
|
||||
disposables.removeFontListeners();
|
||||
disposables.removeTouchListeners();
|
||||
disposables.removeFilePasteListener();
|
||||
disposables.disposeFitAddon();
|
||||
disposables.disposeWebglAddon();
|
||||
disposables.disposeTerminal();
|
||||
@@ -824,6 +884,20 @@ export class TerminalEmulatorRuntime {
|
||||
this.terminal?.paste(text);
|
||||
}
|
||||
|
||||
private pasteClipboard(terminal: Pick<Terminal, "paste">): void {
|
||||
const text = this.clipboard.readText().catch(() => "");
|
||||
const onFilePaste = this.callbacks.onFilePaste;
|
||||
// Report the gesture now so it keeps its place among earlier file pastes.
|
||||
const file = text.then((value) => {
|
||||
if (value) {
|
||||
terminal.paste(value);
|
||||
return null;
|
||||
}
|
||||
return onFilePaste ? readClipboardImage(this.clipboard).catch(() => null) : null;
|
||||
});
|
||||
onFilePaste?.(file);
|
||||
}
|
||||
|
||||
renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void {
|
||||
if (!input.state) {
|
||||
this.clear(input);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user