Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 970b41cb24 docs(plugins): file panel examples and author reference
Adds the pdf-panel and markdown-panel examples, the File panels section of the plugin
reference, and an e2e spec that installs both examples, opens a PDF in the browser
viewer, and edits Markdown through Paseo's document across the switcher, Source,
autosave and conflicts.
2026-10-19 13:40:04 +02:00
Mohamed Boudra 9d2533183b feat(file-panels): choose and forget file panels in Settings > Files
Settings > Files lists each extension or filename where a plugin panel competes with
another candidate, or that has a remembered panel. Each row chooses the panel that opens
it, or Ask every time; Ask again forgets the choice. Source appears as a candidate for
text files. Panels no connected host offers can be forgotten but not chosen.
2026-10-19 13:40:04 +02:00
Mohamed Boudra 18d6d00d66 feat(plugins): file panels
Plugins contribute file panels with addFilePanel. A file tab hosts the panels that claim the
file: the built-in Markdown, HTML and image panels, plugin panels, and Source, Paseo's editor,
which claims every text file and is always the last candidate. The panel is the tab body;
the bar shows the document's status and a switcher between the file's panels (side by side
up to three, two on a phone, a menu beyond that).

Paseo asks which panel opens a file only when a plugin panel is among several candidates,
once per open, and remembers the answer per extension or exact filename when told to. Open
with in the tab menu and the switcher change the tab's panel; Always use remembers it.
Opening a file at a line switches the tab to Source.

Panels read and write the host's document, so dirty state, autosave and conflicts stay
Paseo's. DocumentFrame shows bytes documents such as PDFs in the browser's viewer.

Replaces the file tab's Preview/Source mode toggle.
2026-10-19 13:40:04 +02:00
Mohamed Boudra dd18bc9001 fix(files): report document MIME types for binary files
File reads now carry the document's MIME type for binary files, so a file panel can
hand the bytes to a viewer that needs the real type (application/pdf).
2026-10-19 13:40:04 +02:00
Mohamed Boudra 1c8c68f93e Keep workspace setup logs scoped to the visible setup panel (#7407)
* fix(workspaces): observe setup output only in the visible setup panel

Setup logs were requested for the whole session, including unrelated workspaces. Use directory status for discovery and source-owned workspace observations for details, preserving legacy delivery only for clients without the negotiated capability.

* fix(workspaces): publish external setup progress once per daemon

MCP and scheduled setup enter through the broadcast API. Publish them to the shared setup owner before session fanout so each observation gets one update and snapshots survive periods without connected clients. Keep the setup action available on older hosts to explain the required host update.

* fix(app): replace setup details with each observation snapshot

A host restart can return an empty setup snapshot. Apply that authoritative snapshot so an open panel does not retain output from the previous daemon. Assert setup membership requests as well as delivery in the browser lifecycle regression.

* test(workspaces): cover setup status catch-up after directory release

* test(workspaces): synchronize setup output and release fixture resources
2026-10-19 18:34:53 +07:00
paseo-bot[bot] 19749baeec Keep Android settings inputs visible above the keyboard (#7362)
* fix(app): reveal Android settings inputs above the keyboard

PageLayout kept its full-height scroll viewport while the keyboard controller handled Android insets, leaving Code size covered. Use the existing keyboard-aware scroller for Android pages and cover the real focused field and keyboard geometry in the mobile regression harness.

* fix(app): keep settings field frames above the Android keyboard

Keyboard scrolling revealed only the native text input, leaving the enclosing field border and padding covered. Allow theme spacing around the input and assert the visible Plugins source frame against the real keyboard.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 13:11:20 +02:00
paseo-bot[bot] e760764b37 fix(app): require a number when recording index shortcuts (#7365)
Tab and workspace navigation need a final 1–9 index. Prevent saving an incomplete recorded sequence and explain the requirement while preserving arbitrary chord prefixes.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 11:55:10 +02:00
Mohamed Boudra b6dde61a7f feat(plugins): accept inline SVG and component icons (#7368)
Every contribution `icon` now takes a Lucide name, `{ svg }`, or a
component. Attachment sources take only the first two because composer
drafts persist the source icon.

The daemon's provider-icon check and the app's inline-SVG check are the
same function in the shared SDK root, so a plugin sees the same reasons
in both places. The app parses each icon once at registration and fails
the plugin with that reason. Rendering goes through one module that
turns any form into a component, so surfaces never branch on the form.
2026-10-19 15:36:04 +07:00
Mohamed Boudra 1e0e56c721 refactor(app): unify built-in file panel selection (#7366)
* refactor(app): unify built-in file panel selection

* refactor(app): resolve file panels once per pane
2026-10-19 15:35:41 +07:00
c575464e8f Skip saving unchanged workspace layouts (#7358)
* perf(app): throttle workspace layout writes and skip no-op persists

Zustand persist calls setItem after every set, even when the updater
returns the same state. The layout store re-normalized every workspace
tree, re-validated the envelope, stringified it, and wrote to
AsyncStorage on each agent status change because reconcileTabs runs from
an effect keyed on agent visibility.

The draft store's throttled writer moves to storage/ as a generic
throttled persist storage, gains a referential short-circuit for a value
whose partialized state was already accepted, and now backs both the
draft and layout stores. The layout store's partialize reuses its
previous projection while every persisted slice is the same reference,
and the store is flushed alongside drafts when the app leaves the
foreground.

Before: 50 no-op reconciles produced 50 writes. After: 0 writes; 50 real
sets inside one interval produce 2 writes (leading and trailing).

Fixes #4814

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(app): build layout stores with options and flush before rehydrating

Six call sites still passed the id factories positionally, which this
branch replaced with an options object, so those stores silently used
default ids. The once-only PR test also rehydrated a second store before
the throttled write landed; flush the source first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(app): avoid shadowing layout options

---------

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 10:02:25 +02:00
paseo-ai[bot] 9a0b95aa10 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-19 07:09:09 +00:00
paseo-bot[bot]andaz33zy 9be1547741 fix(app): show a promoted git action once in the compact menu (#7355)
The compact Changes toolbar flattens every git action into one menu, and
`secondary` keeps the actions the policy can promote to `primary`. When the
policy promoted one of them - Pull behind origin, Push ahead of it, the merge
and PR actions - it rendered twice, under the same React key.

Dedupe by id in `actionsWithoutPrimary` so the split button and the command
center share one rule, cover it in policy.test.ts against real buildGitActions
output, and pin the rendered menu in a compact Changes browser spec.

(cherry picked from commit 9bb26cc471)

Co-authored-by: az33zy <10743275+az33zy@users.noreply.github.com>
2026-10-19 08:54:23 +02:00
Mohamed Boudra cafe4b01c8 chore(release): cut 0.12.0 2026-10-19 08:45:41 +02:00
Mohamed Boudra 8dd3e2e34e docs(changelog): prepare 0.12.0 stable 2026-10-19 08:41:01 +02:00
238 changed files with 7902 additions and 5254 deletions
+16 -4
View File
@@ -1,8 +1,8 @@
# Changelog
## 0.12.0-beta.3 - 2026-10-18
## 0.12.0 - 2026-10-19
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
**Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds
@@ -29,9 +29,10 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
- Changed Command Center to rank workspaces and agents by match and then recency, and to show timestamps, project icons, and highlighted matches ([#7277](https://github.com/getpaseo/paseo/pull/7277))
- Added each Paseo subagent's model, thinking, mode, and context under its title in the subagents list ([#7290](https://github.com/getpaseo/paseo/pull/7290))
- Made subagent rows in the Subagents panel taller on phones, and gave their archive and detach buttons the standard press highlight ([#7364](https://github.com/getpaseo/paseo/pull/7364))
- Changed Paseo tool calls in chats to lead with the action and its context, such as Send message · the first line of the prompt ([#7283](https://github.com/getpaseo/paseo/pull/7283))
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8), [#7342](https://github.com/getpaseo/paseo/pull/7342))
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242))
- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells))
@@ -46,6 +47,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
- Fixed follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri))
- Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso))
- Fixed late-loading composer plugin pills covering Copy and Fork after reopening a chat ([#7341](https://github.com/getpaseo/paseo/pull/7341) by [@sholiday](https://github.com/sholiday))
### Fixed
@@ -59,6 +61,8 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed the project's New workspace button choosing the first host instead of the active workspace's host ([#7308](https://github.com/getpaseo/paseo/pull/7308))
- Fixed the composer staying focused after switching tabs with keyboard shortcuts in touch browsers ([#7352](https://github.com/getpaseo/paseo/pull/7352))
- Fixed the daemon running out of memory when several directory searches overlap on a large folder tree ([#7265](https://github.com/getpaseo/paseo/pull/7265))
- Fixed dragging the Explorer edge stuttering with several panes open ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
@@ -68,6 +72,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
- Fixed resumed Codex goals appearing idle and refusing Stop after a continuation ([#7339](https://github.com/getpaseo/paseo/pull/7339) by [@hemaaanth](https://github.com/hemaaanth))
- Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone))
- Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger))
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
@@ -99,9 +104,13 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542))
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed MCP terminal key commands typing arrow and modifier key names instead of sending the keys ([#7343](https://github.com/getpaseo/paseo/pull/7343) by [@Aling33](https://github.com/Aling33))
- Fixed CJK text gaining spaces between characters after returning to a workspace in the native terminal ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed copying Korean text from the terminal on iOS and Android adding spaces between characters or dropping the first one ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman))
- Fixed long-pressing a Korean word in the terminal on iOS and Android selecting only one character instead of the whole word ([#7353](https://github.com/getpaseo/paseo/pull/7353))
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
- Fixed the Hub connection never reconnecting after a silent network failure ([#7315](https://github.com/getpaseo/paseo/pull/7315))
- Fixed remote connections failing on slow networks when another resolved address is unreachable ([#7336](https://github.com/getpaseo/paseo/pull/7336))
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
@@ -158,7 +167,10 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
- Fixed usage cards losing their last values and showing "Updated" when a refresh fails ([#7246](https://github.com/getpaseo/paseo/pull/7246))
- Fixed Usage merging distinct Cursor accounts into one card ([#7344](https://github.com/getpaseo/paseo/pull/7344))
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
- Fixed generic icons for Devin, Hermes, Kiro, and Antigravity in provider lists ([#7330](https://github.com/getpaseo/paseo/pull/7330) by [@OMEG-Lu](https://github.com/OMEG-Lu), [@kevenhu001-cyber](https://github.com/kevenhu001-cyber))
- Fixed sidebar setup buttons overflowing the card with long translated labels ([#7312](https://github.com/getpaseo/paseo/pull/7312) by [@gentslava](https://github.com/gentslava))
## 0.11.4 - 2026-10-15
-1
View File
@@ -46,7 +46,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
-3
View File
@@ -4,9 +4,6 @@ Paseo is a client-server system for monitoring and controlling local AI coding a
Your code never leaves your machine. Paseo is local-first.
Daemon-owned outbound connections and routed API scope ownership are described in
[Connected hosts](connected-hosts.md).
## System overview
```
-55
View File
@@ -1,55 +0,0 @@
# Connected hosts
A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list.
Connect A to B in A's settings under **Hosts connected to this daemon**, through `paseo --host A host`,
or through `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable
`registrationId` created before B is reachable.
## Ownership and authority
Credentials live in A's `hosts.json`, written atomically with mode `0600` on POSIX systems. Discovery returns a safe
target description. Replacement inputs never come back through a read API. Keep this file private
when collecting diagnostics or backing up daemon state.
The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry,
including a user's disabled state. Changed input requires an explicit update. Display labels and
access can be changed by users with `tunnel.manage`; connection and metadata changes require the
producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again.
Disabling retains the opt-out.
Each routed API owns a separate session. A derives its identity from the admitted origin session,
producer, and logical SDK scope. A replaces forwarded identity and grants, and B intersects those
grants with the authenticated link's authority. Reconnect reads current authority. Changing caller
permissions closes existing routes. A routed API cannot select a second hop.
A producer that registers a host through a routed API is qualified on B as
`route:<B principal>:<A serverId>:<A producer>`, with each component percent-encoded. This namespace belongs to the authenticated link
principal. An ordinary direct B consumer does not become that producer by supplying a source key.
The optional `server_info.sourceId` reports this producer identity for source-owner controls; it does not describe the authenticated admission principal.
Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
## Connections and recovery
The shared Node connector owns the target grammar and each connection attempt, including a fresh
SSH process. SSH reaches an already running daemon. The daemon keeps unreachable registrations and
retries them. Identity is pinned when discovered. Self and duplicate registrations remain inspectable;
a changed destination requires explicit identity replacement.
Both daemons must advertise `connectedHosts`. A checks B's capability and identity before forwarding
session operations. There is no local fallback. An older daemon continues to serve existing local
operations; requesting this feature gives update guidance.
Disposing a routed API releases its observations and session. It does not remove the saved host or
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
through the usual owned-subscription contract; requests made while offline fail. Losing a response
returns `outcome_unknown` with the destination ID. Inspect the destination before retrying a mutation.
No mutation is automatically replayed.
Host links cap saved registrations, simultaneous routes, frames, per-socket output, and shared queued
bytes. Slow consumers lose their route with a resource-limit error instead of retaining unbounded
output. Routed sessions release server resources on disconnect; subscription demand is rebuilt by
its owning client on reconnect.
See the [SDK reference](../public-docs/sdk/reference.md#clienthosts),
[CLI reference](../public-docs/cli.md#connected-hosts), and
[plugin reference](../public-docs/plugins/reference.md#connected-hosts).
-4
View File
@@ -31,10 +31,6 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement
requires the producer or `access.manage`. Routed operations retain both caller and link authority,
see [Connected hosts](connected-hosts.md#ownership-and-authority).
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
+38 -9
View File
@@ -240,8 +240,9 @@ Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
[host UI contract](../public-docs/plugins/reference.md#host-ui).
@@ -332,7 +333,7 @@ created by plugin code. Paseo removes registered contributions, unmounts surface
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
@@ -400,6 +401,25 @@ See the public [navigation fields](../public-docs/plugins/reference.md#screens-a
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
### Plugin icons
The [public reference](../public-docs/plugins/reference.md#icons) owns the author API: a contribution
`icon` is a Lucide name, `{ svg }`, or a component.
- `findSvgIconProblem` in the SDK root (`packages/plugin/src/icons.ts`) is the only SVG rule set.
The daemon applies it to provider and usage-source icon files; the app applies it to inline
icons. Change the rules there, never at a call site.
- The app validates each icon once, at registration, through `parsePluginIcon` in
`packages/app/src/plugins/icons.ts`. Rendering trusts the stored icon.
- That module is the only code that branches on the icon form. Surfaces draw with
`PluginIconView`, or take a stable component from `pluginIconComponent` when their slot expects
one. Lucide and SVG icons receive only size and color, so the host props a button passes to its
component icon never reach an SVG element.
- Attachment sources take `PluginSerializableIcon`: composer drafts persist the source's icon with
each attachment, and a component cannot be stored.
- The plugin compiler loads `.svg` imports as text, so `import icon from "./client/icon.svg"` is the
markup string.
## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with
@@ -491,15 +511,13 @@ provider implementation. The public [provider plugin guide](../public-docs/plugi
owns author workflow, lifecycle, testing, and distribution guidance.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
It must resolve inside that directory to a regular file that passes the shared SVG icon rules
(see [Plugin icons](#plugin-icons)). Paseo reads and validates the file when the plugin starts; the
string is never inline SVG or a URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
@@ -538,6 +556,17 @@ one section's order from built-ins, plugin groups, and the section's preference
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute file panels
Client `addFilePanel` registrations become the body of a file tab, rendering host-owned text or
bytes. The tab's bar owns the document's status (size, lines, save state, conflicts) and switches
between the file's panels; Source, Paseo's editor, is a built-in panel for every text file and
always the last choice. Keep editing in the host document so autosave and conflicts have one
owner. See the
[public file panel reference](../public-docs/plugins/reference.md#file-panels) for selectors,
selection, document props, `DocumentFrame`, and the version requirement. The examples live in
`plugin-examples/pdf-panel` and `plugin-examples/markdown-panel`.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
+8
View File
@@ -74,6 +74,14 @@ The adapter keys legacy slots by physical socket, so an old connection cannot re
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
for parsing compatibility; modern requests cannot select their subscription ID.
Workspace setup details negotiate `workspace_setup_observation` with
`server_info.features.workspaceSetupObservation`. Capable clients cannot request details through
the legacy session event category. The setup panel owns a workspace-scoped observation while
active and visible; directory metadata signals failures without requesting output. Older clients
retain their legacy event delivery. A new app makes no setup event demand on older hosts and
requires a host update to view details. Subscription-capable older hosts honor that absence of
demand; broadcast-era hosts require an update before wire silence is guaranteed.
## Every shim is tagged and dated
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
@@ -0,0 +1,8 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
sha256-ObBdUmNSW+vDjzA7EbCDfZrB0nYDmcWmWe+UCoTMh/M=
+28 -29
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36349,7 +36349,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37469,12 +37469,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/server": "0.12.0-beta.3",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/server": "0.12.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37721,11 +37721,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"ws": "^8.20.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37736,7 +37735,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37980,7 +37979,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38876,7 +38875,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38905,14 +38904,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38922,8 +38921,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38945,7 +38944,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38972,7 +38971,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39190,16 +39189,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/highlight": "0.12.0-beta.3",
"@getpaseo/plugin": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"@getpaseo/client": "0.12.0",
"@getpaseo/highlight": "0.12.0",
"@getpaseo/plugin": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40120,7 +40119,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41728,7 +41727,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,115 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo, resolveTempRoot } from "../support/helpers/workspace";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
// The compact toolbar flattens every git action into one menu, and the policy keeps the actions
// it can promote to `primary` in `secondary` as well. A promoted action must still show once.
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const task of cleanupTasks.splice(0)) {
await task();
}
});
async function createRemoteRepo(prefix: string): Promise<string> {
const repo = await createTempGitRepo(prefix, {
withRemote: true,
files: [{ path: "src/app.ts", content: "export const app = 1;\n" }],
});
cleanupTasks.push(repo.cleanup);
// The helper puts the bare remote inside the checkout; keep it out of the status.
await writeFile(path.join(repo.path, ".git/info/exclude"), "remote.git/\n");
return repo.path;
}
async function registerWorkspace(repoPath: string): Promise<string> {
const client = await connectSeedClient();
cleanupTasks.push(async () => {
await client.close().catch(() => undefined);
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repoPath } });
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${repoPath}`);
}
return created.workspace.id;
}
async function openCompactActionsMenu(page: Page, workspaceId: string) {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
await page.reload();
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
const trigger = explorer.getByTestId("changes-actions-menu-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const menu = page.getByTestId("changes-primary-cta-menu");
await expect(menu).toBeVisible();
return menu;
}
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-pull-");
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", repoPath]);
});
// Advance origin so the clean checkout is behind it and Pull is the promoted action.
const other = await mkdtemp(path.join(await resolveTempRoot(), "compact-menu-other-"));
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", other]);
});
execFileSync("git", ["clone", path.join(repoPath, "remote.git"), other], { stdio: "ignore" });
execFileSync("git", ["config", "user.email", "e2e@paseo.test"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo E2E"], { cwd: other, stdio: "ignore" });
await writeFile(path.join(other, "src/incoming.ts"), "export const incoming = 1;\n");
execFileSync("git", ["add", "--all"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Incoming commit"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["push", "origin", "main"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["fetch", "origin"], { cwd: repoPath, stdio: "ignore" });
const workspaceId = await registerWorkspace(repoPath);
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Pull",
"Push",
"Pull and push",
"Archive workspace",
]);
});
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-push-");
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", repoPath]);
});
const workspaceId = await registerWorkspace(repoPath);
// Commit so the clean checkout is ahead of origin and Push is the promoted action.
await writeFile(path.join(repoPath, "src/mine.ts"), "export const mine = 1;\n");
execFileSync("git", ["add", "--all"], { cwd: repoPath, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Local commit"], { cwd: repoPath, stdio: "ignore" });
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Push",
"Pull",
"Pull and push",
"Archive workspace",
]);
});
@@ -1,66 +0,0 @@
import { test } from "../support/fixtures";
import {
openRestartableConnectedHosts,
beginConnectedHostDraft,
reconnectManagingHost,
expectConnectedHostDraftRetained,
saveConnectedHost,
openConnectedHosts,
openCompactConnectedHosts,
addConnectedHost,
expectConnectionError,
correctConnection,
inspectConnectedHost,
expectSavedHostDetails,
disableConnectedHost,
enableConnectedHost,
editConnectedHost,
removeConnectedHost,
} from "../support/helpers/connected-hosts";
test("invalid input stays editable; unreachable hosts remain manageable", async ({
page,
}, testInfo) => {
await openConnectedHosts(page);
await addConnectedHost(page, "ssh://-invalid", "Build box");
await expectConnectionError(page);
await correctConnection(page, "tcp://127.0.0.1:1?password=private-secret");
await inspectConnectedHost(page, "Build box");
await expectSavedHostDetails(page);
await page.screenshot({ path: testInfo.outputPath("connected-host-details.png") });
await editConnectedHost(page);
await disableConnectedHost(page);
await enableConnectedHost(page);
await removeConnectedHost(page, "Renamed box");
});
test.describe("compact connected hosts", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("saved hosts can be inspected and disabled on a phone layout", async ({
page,
}, testInfo) => {
await openCompactConnectedHosts(page);
await addConnectedHost(page, "tcp://127.0.0.1:1", "Compact host");
await inspectConnectedHost(page, "Compact host");
await expectSavedHostDetails(page);
await page.screenshot({
path: testInfo.outputPath("connected-host-compact.png"),
fullPage: true,
});
await disableConnectedHost(page);
await removeConnectedHost(page, "Compact host");
});
});
test("connection loss retains the unsaved name and credential input", async ({ page }) => {
const host = await openRestartableConnectedHosts(page);
try {
await beginConnectedHostDraft(page);
await reconnectManagingHost(host);
await expectConnectedHostDraftRetained(page);
await saveConnectedHost(page);
await inspectConnectedHost(page, "Retained draft");
} finally {
await host.close();
}
});
+16 -14
View File
@@ -55,6 +55,10 @@ async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
await expectFileTabOpen(page, filename);
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
function htmlPreview(page: Page) {
return {
host: page.getByTestId("file-html-preview"),
@@ -62,11 +66,9 @@ function htmlPreview(page: Page) {
};
}
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
const option = page.getByTestId("file-panel-bar").getByRole("button", {
name: view,
exact: true,
});
/** Switches the file tab to one of its panels from the bar's switcher. */
async function selectFilePanel(page: Page, title: string): Promise<void> {
const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true });
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
@@ -484,9 +486,9 @@ test.describe("CodeMirror workspace file editing", () => {
relativePath,
);
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
const modeControl = page.getByTestId("file-preview-mode");
const modeControl = filePanelSwitcher(page);
await expect(modeControl).toBeVisible();
await selectFileView(page, "Source");
await selectFilePanel(page, "Source");
const editorHost = page.getByTestId("file-source-editor");
const content = editor(page);
@@ -548,7 +550,7 @@ test.describe("CodeMirror workspace file editing", () => {
);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
await selectFileView(page, "Source");
await selectFilePanel(page, "Source");
const markdownScroller = page
.getByTestId("file-source-editor")
@@ -680,7 +682,7 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
});
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
test("refreshes Markdown and images while switching between Markdown and Source", async ({
page,
withWorkspace,
}) => {
@@ -695,15 +697,15 @@ test.describe("CodeMirror workspace file editing", () => {
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8");
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
await selectFileView(page, "Source");
await selectFilePanel(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await replaceEditorText(page, "# Saved from source\n");
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await selectFileView(page, "Preview");
await selectFilePanel(page, "Markdown");
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
await openWorkspaceFile(page, "pixel.png");
@@ -786,10 +788,10 @@ test.describe("CodeMirror workspace file editing", () => {
);
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
await selectFileView(page, "Source");
await selectFilePanel(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(preview.host).toHaveCount(0);
await selectFileView(page, "Preview");
await selectFilePanel(page, "HTML");
await expect(preview.host).toBeVisible();
});
@@ -14,8 +14,9 @@ async function openMarkdownPreview(page: Page) {
await openFileFromExplorer(page, "README.md");
await expectFileTabOpen(page, "README.md");
await page
.getByTestId("file-panel-bar")
.getByRole("button", { name: "Preview", exact: true })
.getByTestId("file-panel-switcher")
.filter({ visible: true })
.getByRole("button", { name: "Markdown", exact: true })
.click();
await ensureWorkspaceAgentPaneVisible(page);
}
@@ -0,0 +1,272 @@
import { execFileSync } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Frame, TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
} from "../support/helpers/file-explorer";
// Full Chromium includes the PDF viewer; the default headless shell does not.
test.use({ channel: "chromium" });
function samplePdf() {
const stream = "BT /F1 24 Tf 72 720 Td (Paseo PDF panel example) Tj ET";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
];
let pdf = "%PDF-1.4\n%\u0000\u00ff\n";
const offsets = objects.map((object, index) => {
const offset = Buffer.byteLength(pdf);
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
return offset;
});
const xref = Buffer.byteLength(pdf);
pdf += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}`;
pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return pdf;
}
async function openFile(page: Page, name: string) {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
const WIDE = { width: 1280, height: 900 };
const COMPACT = { width: 390, height: 844 };
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
/** Captures the current state wide, then compact once `ready` holds there, and returns to wide. */
async function captureWideAndCompact(
page: Page,
testInfo: TestInfo,
name: string,
ready: () => Promise<void>,
) {
await capture(page, testInfo, `${name}-wide`);
await page.setViewportSize(COMPACT);
await ready();
await capture(page, testInfo, `${name}-compact`);
await page.setViewportSize(WIDE);
await ready();
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
/** Turns on vim keybindings without discarding settings the test writes later. */
async function enableVimKeybindings(page: Page) {
await page.addInitScript(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
localStorage.setItem(key, JSON.stringify({ ...settings, vimKeybindings: true }));
});
}
async function chooseRichMarkdown(page: Page) {
await page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }).click();
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
await page.getByRole("button", { name: "Use", exact: true }).click();
}
async function replaceMarkdown(page: Page, text: string) {
await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text);
}
async function selectPanel(page: Page, title: string) {
await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click();
}
async function expectPanels(page: Page, titles: string[]) {
for (const title of titles) {
await expect(
filePanelSwitcher(page).getByRole("button", { name: title, exact: true }),
).toBeInViewport({ ratio: 1 });
}
}
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
async function pdfViewer(page: Page): Promise<Frame> {
const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/;
const find = () =>
page.frames().find((frame) => !frame.isDetached() && viewerUrl.test(frame.url())) ?? null;
await expect.poll(() => find() !== null).toBe(true);
return find()!;
}
async function expectPdfViewerReady(page: Page) {
// A narrow viewer collapses its toolbar to Print and More actions.
const viewer = await pdfViewer(page);
await expect(viewer.getByRole("button", { name: "Print", exact: true })).toBeEnabled();
}
async function expectMarkdownEditor(page: Page, text: string) {
const editor = page.getByRole("textbox", { name: "Rich Markdown editor", exact: true });
await expect(editor).toBeVisible();
await expect(editor).toContainText(text);
await expect(editor).toBeInViewport({ ratio: 1 });
// Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu.
await expect(filePanelSwitcher(page)).toContainText("Rich Markdown");
await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 });
}
test("directory examples mount the PDF frame and edit Markdown through Paseo's document", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const pdf = await copyPluginExample("pdf-panel");
const markdown = await copyPluginExample("markdown-panel");
execFileSync(
process.platform === "win32" ? "npm.cmd" : "npm",
["install", "--ignore-scripts", "--no-audit", "--no-fund"],
{ cwd: markdown.directory, stdio: "pipe" },
);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-examples-",
repo: {
files: [
{
path: "notes.md",
content: "# Markdown panel example\n\nEdit **Markdown** through Paseo.\n",
},
{ path: "second.md", content: "# Remembered panel\n" },
{ path: "report.pdf", content: samplePdf() },
],
},
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(pdf.directory);
await client.installDirectoryPlugin(markdown.directory);
await enableVimKeybindings(page);
await page.setViewportSize(WIDE);
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openFile(page, "report.pdf");
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"src",
/^blob:/,
);
await expect(page.getByTitle("Document preview", { exact: true })).not.toHaveAttribute(
"sandbox",
);
const documentUrl = await page
.getByTitle("Document preview", { exact: true })
.getAttribute("src");
expect(
await page.evaluate(
async (url) => (await fetch(url!)).blob().then((blob) => blob.type),
documentUrl,
),
).toBe("application/pdf");
await expectPdfViewerReady(page);
await expect(
(await pdfViewer(page)).getByRole("textbox", { name: "Page number", exact: true }),
).toHaveValue("1");
await captureWideAndCompact(page, testInfo, "pdf-example", async () => {
await expect(page.getByTitle("Document preview", { exact: true })).toBeInViewport();
await expectPdfViewerReady(page);
});
await openFile(page, "notes.md");
await expect(page.getByRole("radio", { name: "Markdown — Paseo", exact: true })).toBeChecked();
await expect(
page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }),
).toBeVisible();
await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
ratio: 1,
});
});
await chooseRichMarkdown(page);
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
"Markdown panel example",
);
gate.holdNextClientRequest("fs.file.write.request");
await replaceMarkdown(page, "# Edited by Rich Markdown\n\nAutosaved by Paseo.\n");
await gate.waitForHeldClientRequest();
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
await capture(page, testInfo, "markdown-example-dirty-wide");
gate.releaseHeldClientRequest();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Edited by Rich Markdown\n\nAutosaved by Paseo.\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]);
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
await expectMarkdownEditor(page, "Edited by Rich Markdown");
});
// Source is Paseo's editor; its cursor and vim mode join the bar's status.
await selectPanel(page, "Source");
const source = page.getByTestId("file-source-editor").filter({ visible: true });
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
await source.locator(".cm-line").nth(2).click();
const bar = page.getByTestId("file-panel-bar").filter({ visible: true });
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
await capture(page, testInfo, "markdown-example-source-wide");
// The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source.
await page.setViewportSize(COMPACT);
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
await filePanelSwitcher(page).click();
await page.getByRole("menuitem", { name: "Source", exact: true }).click();
await expect(filePanelSwitcher(page)).toHaveText("Source");
await source.locator(".cm-line").nth(2).click();
await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport();
await capture(page, testInfo, "markdown-example-source-compact");
await page.setViewportSize(WIDE);
await selectPanel(page, "Rich Markdown");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText(
"# External update",
);
await replaceMarkdown(page, "# Local edit\n");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Conflicting external edit\n");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
await capture(page, testInfo, "markdown-example-conflict-wide");
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Local edit\n");
await openFile(page, "second.md");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
"Remembered panel",
);
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await page.setViewportSize(COMPACT);
await expectMarkdownEditor(page, "Remembered panel");
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
} finally {
await client.removePlugin("pdf-panel");
await client.removePlugin("markdown-panel");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close();
await workspace.cleanup();
await pdf.cleanup();
await markdown.cleanup();
}
});
@@ -0,0 +1,156 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import { expectFileTabOpen, openFileExplorer } from "../support/helpers/file-explorer";
import {
BUILTIN_MARKDOWN_OPTION,
MARKDOWN_PANEL_ID,
PLUGIN_MARKDOWN_OPTION,
expectBuiltinMarkdown,
expectPluginMarkdown,
filePanelPicker,
installMarkdownPanelPlugin,
openMarkdown,
storedFilePanelAssociations,
} from "../support/helpers/markdown-file-panel-plugin";
async function choosePanel(page: Page, option: string, options: { remember: boolean }) {
await page.getByRole("radio", { name: option, exact: true }).click();
if (options.remember) {
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
}
await page.getByRole("button", { name: "Use", exact: true }).click();
await expect(filePanelPicker(page)).toBeHidden();
}
async function openWithFromTabMenu(page: Page, name: string, option: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await page.getByRole("menuitem", { name: "Open with…" }).hover();
await page.getByRole("menuitem", { name: option }).click();
}
async function toggleAlwaysUseFromFileBar(page: Page) {
await page.getByTestId("file-panel-bar-menu").filter({ visible: true }).click();
await page.getByRole("menuitem", { name: "Always use for .md" }).click();
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
async function expectSwitcherPanels(page: Page, titles: string[]) {
await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles);
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
test("opens Markdown with the built-in panel and Source beside it without asking", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: "file-panel-builtins-",
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
});
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openMarkdown(page, "notes.md");
await expectBuiltinMarkdown(page, "notes heading");
await expectSwitcherPanels(page, ["Markdown", "Source"]);
await expect(filePanelPicker(page)).toBeHidden();
await capture(page, testInfo, "file-panel-builtins-wide");
await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click();
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click();
await expectBuiltinMarkdown(page, "notes heading");
await page.setViewportSize({ width: 390, height: 844 });
await expectBuiltinMarkdown(page, "notes heading");
await expect(
filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }),
).toBeInViewport({ ratio: 1 });
await expect(filePanelPicker(page)).toBeHidden();
await capture(page, testInfo, "file-panel-builtins-compact");
} finally {
await workspace.cleanup();
}
});
test("asks once which panel opens Markdown and remembers it when told to", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-picker-",
repo: {
files: ["first", "second", "third", "fourth"].map((name) => ({
path: `${name}.md`,
content: `# ${name} heading\n`,
})),
},
});
const plugin = await installMarkdownPanelPlugin();
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
// First open: the built-in renders under the picker.
await openMarkdown(page, "first.md");
await expect(filePanelPicker(page)).toBeVisible();
await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked();
await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked();
await expectBuiltinMarkdown(page, "first heading");
await capture(page, testInfo, "file-panel-picker-wide");
// Don't ask me again: the plugin renders and later opens use it silently.
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: true });
await expectPluginMarkdown(page, "first.md");
expect(await storedFilePanelAssociations(page)).toEqual({ "ext:md": MARKDOWN_PANEL_ID });
await openMarkdown(page, "second.md");
await expectPluginMarkdown(page, "second.md");
await expect(filePanelPicker(page)).toBeHidden();
// Forgetting from the file bar keeps the tab on the plugin.
await toggleAlwaysUseFromFileBar(page);
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
await expectPluginMarkdown(page, "second.md");
// An unremembered choice applies to this tab only; the next open asks again.
await openMarkdown(page, "third.md");
await expect(filePanelPicker(page)).toBeVisible();
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: false });
await expectPluginMarkdown(page, "third.md");
expect(await storedFilePanelAssociations(page)).toEqual({});
// "Open with…" switches the open tab back to the built-in in place.
await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION);
await expectBuiltinMarkdown(page, "third heading");
await expectFileTabOpen(page, "third.md");
// Dismissing keeps the built-in; reopening the tab after a reload asks again.
await openMarkdown(page, "fourth.md");
await expect(filePanelPicker(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(filePanelPicker(page)).toBeHidden();
await expectBuiltinMarkdown(page, "fourth heading");
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
ratio: 1,
});
await capture(page, testInfo, "file-panel-picker-compact");
} finally {
await plugin.uninstall();
await workspace.cleanup();
}
});
@@ -0,0 +1,144 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openSettings } from "../support/helpers/app";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
BUILTIN_MARKDOWN_OPTION,
MARKDOWN_PANEL_ID,
PLUGIN_MARKDOWN_OPTION,
SOURCE_OPTION,
expectPluginMarkdown,
filePanelPicker,
installMarkdownPanelPlugin,
openMarkdown,
storedFilePanelAssociations,
} from "../support/helpers/markdown-file-panel-plugin";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSettingsSection } from "../support/helpers/settings";
const ASK_EVERY_TIME = "Ask every time";
function markdownRow(page: Page) {
return page.getByTestId("file-panel-settings-row-ext:md");
}
function markdownDropdown(page: Page, value: string) {
return markdownRow(page).getByRole("button", { name: `.md: ${value}`, exact: true });
}
function askAgain(page: Page) {
return markdownRow(page).getByRole("button", { name: "Ask again for .md", exact: true });
}
async function openFilesSettings(page: Page) {
await openSettings(page);
await openSettingsSection(page, "files");
await expect(page.getByTestId("file-panel-settings")).toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
async function expectMenuOpen(page: Page) {
await expect(page.getByTestId("file-panel-settings-menu")).toHaveCSS("opacity", "1");
}
test("Settings > Files chooses which panel opens .md and asks again on request", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-settings-",
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
});
const plugin = await installMarkdownPanelPlugin();
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFilesSettings(page);
// Nothing remembered: the row asks every time and has nothing to forget.
await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible({ timeout: 30_000 });
await expect(askAgain(page)).toBeDisabled();
await capture(page, testInfo, "file-panel-settings-wide");
await markdownDropdown(page, ASK_EVERY_TIME).click();
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-wide-menu");
await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click();
await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible();
await expect
.poll(() => storedFilePanelAssociations(page))
.toEqual({
"ext:md": MARKDOWN_PANEL_ID,
});
await capture(page, testInfo, "file-panel-settings-wide-chosen");
// The next open uses the plugin without asking.
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openMarkdown(page, "notes.md");
await expectPluginMarkdown(page, "notes.md");
await expect(filePanelPicker(page)).toBeHidden();
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/settings/files");
await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible({ timeout: 30_000 });
await expect(askAgain(page)).toBeInViewport({ ratio: 1 });
await capture(page, testInfo, "file-panel-settings-compact");
await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-compact-menu");
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
// Ask again forgets the choice; the next open asks.
await askAgain(page).click();
await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible();
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
} finally {
await plugin.uninstall();
await workspace.cleanup();
}
});
test("Settings > Files explains where file panels come from when no plugin adds one", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/settings/files");
const empty = page.getByTestId("file-panel-settings-empty");
await expect(empty).toHaveText("File panels appear here when a plugin adds one", {
timeout: 30_000,
});
await capture(page, testInfo, "file-panel-settings-empty-wide");
await page.setViewportSize({ width: 390, height: 844 });
await expect(empty).toBeVisible();
await capture(page, testInfo, "file-panel-settings-empty-compact");
});
test("Settings > Files lets a panel no host has be forgotten but not chosen", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ filePanelAssociations: { "ext:pdf": "plugin:none/none" } }),
);
});
await page.goto("/settings/files");
const row = page.getByTestId("file-panel-settings-row-ext:pdf");
const dropdown = row.getByRole("button", { name: ".pdf: Not on a connected host" });
await expect(dropdown).toBeDisabled({ timeout: 30_000 });
await row.getByRole("button", { name: "Ask again for .pdf", exact: true }).click();
await expect(page.getByTestId("file-panel-settings-empty")).toBeVisible();
});
@@ -0,0 +1,185 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
} from "../support/helpers/file-explorer";
const PLUGIN_ID = "file-panel-e2e";
const PDF = Buffer.from("%PDF-1.4\n%\x00\xff\n1 0 obj<</Type/Catalog>>endobj\n%%EOF", "binary");
const SOURCE = `import React, { useEffect, useState } from "react";
import { Text, View, Pressable } from "react-native";
import { usePaseo, useWorkspace } from "@getpaseo/plugin/client";
import { useQueryClient } from "@tanstack/react-query";
import { DocumentFrame } from "@getpaseo/plugin/client/ui";
function Pdf({ document, file, workspaceId }) {
usePaseo();
useQueryClient();
const workspace = useWorkspace(workspaceId, value => ({ id: value.id }));
const [urlBytes, setUrlBytes] = useState(-1);
useEffect(() => {
fetch(document.url).then(response => response.arrayBuffer()).then(bytes => setUrlBytes(bytes.byteLength));
}, [document.url]);
return <View style={{ flex: 1 }}>
<Text>PDF plugin: {file.name}</Text>
<Text>Path {file.path}</Text>
<Text>Bytes {document.bytes.length}; URL bytes {urlBytes}</Text>
<Text>Workspace {workspace?.id}</Text>
<DocumentFrame url={document.url} testID="plugin-pdf-frame" />
</View>;
}
function Markdown({ document }) {
return <View>
<Text>Plugin Markdown: {document.text}</Text>
<Text>{document.editable ? "Plugin document editable" : "Plugin document readonly"}</Text>
<Pressable accessibilityRole="button" onPress={() => document.setText("# Saved by plugin\\n")}>
<Text>Edit Markdown through plugin</Text>
</Pressable>
</View>;
}
export default function contribute(client) {
client.addFilePanel({ id: "pdf", title: "PDF", icon: "FileText",
files: { extensions: ["pdf", "txt"] }, document: "bytes", Component: Pdf });
client.addFilePanel({ id: "markdown", title: "Plugin Markdown", icon: "FileText",
files: { extensions: ["md"] }, document: "text", Component: Markdown });
return () => {};
}`;
async function openWorkspaceFile(page: Page, name: string) {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
async function associateMarkdownWithPlugin(page: Page) {
await page.evaluate(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
settings.filePanelAssociations = { "ext:md": "plugin:file-panel-e2e/markdown" };
localStorage.setItem(key, JSON.stringify(settings));
});
await page.reload();
}
async function editMarkdownThroughPlugin(page: Page) {
await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click();
}
async function selectSource(page: Page) {
await page
.getByTestId("file-panel-switcher")
.filter({ visible: true })
.getByRole("button", { name: "Source", exact: true })
.click();
}
test("directory plugins render PDF bytes and edit associated Markdown through the host document", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const directory = await mkdtemp(path.join(tmpdir(), "paseo-file-panels-plugin-"));
const workspace = await seedWorkspace({
repoPrefix: "file-panels-",
repo: {
files: [
{ path: "notes.md", content: "# Built-in Markdown\n" },
{ path: "bytes.txt", content: "Bytes panel text document" },
],
},
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(workspace.repoPath, "report.pdf"), PDF);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openWorkspaceFile(page, "report.pdf");
await expect(page.getByText("PDF plugin: report.pdf", { exact: true })).toBeVisible();
await expect(
page.getByText(`Bytes ${PDF.length}; URL bytes ${PDF.length}`, { exact: true }),
).toBeVisible();
await expect(
page.getByText(`Workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"src",
/^blob:/,
);
await expect(
page.getByText(`Path ${path.join(workspace.repoPath, "report.pdf")}`, { exact: true }),
).toBeVisible();
const pdfScreenshot = testInfo.outputPath("plugin-pdf-web.png");
await page.screenshot({ path: pdfScreenshot });
await testInfo.attach("plugin-pdf-web", { path: pdfScreenshot, contentType: "image/png" });
// A plugin panel claiming a text file competes with Source, so Paseo asks over the plugin.
await openWorkspaceFile(page, "bytes.txt");
await expect(page.getByText("Open .txt files with", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Open .txt files with", { exact: true })).toBeHidden();
await expect(page.getByText("PDF plugin: bytes.txt", { exact: true })).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"sandbox",
"",
);
await expect(
page
.frameLocator('[title="Document preview"]')
.getByText("Bytes panel text document", { exact: true }),
).toBeVisible();
await openWorkspaceFile(page, "notes.md");
await expect(page.getByText("Open .md files with", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await expect(
page.getByRole("heading", { name: "Built-in Markdown", exact: true }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Edit Markdown through plugin" })).toHaveCount(0);
await associateMarkdownWithPlugin(page);
await expect(page.getByText("Plugin document editable", { exact: true })).toBeVisible();
gate.holdNextClientRequest("fs.file.write.request");
await editMarkdownThroughPlugin(page);
await gate.waitForHeldClientRequest();
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
gate.releaseHeldClientRequest();
await expect(
page.getByText("Plugin Markdown: # Saved by plugin", { exact: true }),
).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Saved by plugin\n");
await selectSource(page);
await expect(
page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"),
).toHaveText("# Saved by plugin");
const markdownScreenshot = testInfo.outputPath("plugin-markdown-source-web.png");
await page.screenshot({ path: markdownScreenshot });
await testInfo.attach("plugin-markdown-source-web", {
path: markdownScreenshot,
contentType: "image/png",
});
} finally {
await client.removePlugin(PLUGIN_ID);
await client.patchDaemonConfig({
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
});
await client.close();
await workspace.cleanup();
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,138 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
const PLUGIN_ID = "plugin-icons-e2e";
const SVG_PATH = "M4 12l5 5L20 6";
const pluginClientSource = `import React from "react";
import { Text, View } from "react-native";
const checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="${SVG_PATH}" /></svg>';
function BadgeIcon({ size, color }) {
return <View testID="plugin-component-icon" style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
}
function Panel() {
return <View><Text>Plugin icon panel</Text></View>;
}
const panels = [
{ id: "lucide", title: "Lucide icon panel", icon: "Scan" },
{ id: "svg", title: "SVG icon panel", icon: { svg: checkIcon } },
{ id: "component", title: "Component icon panel", icon: BadgeIcon },
];
export default function contribute(client) {
for (const panel of panels) {
client.addWorkspacePanel({ ...panel, context: "workspace", Component: Panel });
client.addCommandCenterItem({
id: panel.id,
title: "Open " + panel.title,
icon: panel.icon,
context: "workspace",
onSelect({ openPanel }) { openPanel(panel.id); },
});
}
return () => {};
}`;
function pluginTab(page: Page, panelId: string): Locator {
const identity = `${PLUGIN_ID.length}_${PLUGIN_ID}_${panelId.length}_${panelId}`;
return page.getByTestId(`workspace-tab-plugin_workspace_${identity}`).filter({ visible: true });
}
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const screenshot = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
}
test.describe("plugin icons", () => {
test.describe.configure({ timeout: 180_000 });
test("renders a Lucide name, inline SVG, and a component", async ({ page }, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icons-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const workspace = await seedWorkspace({ repoPrefix: "plugin-icons-" });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginClientSource);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
await waitForWorkspaceInSidebar(page, target);
await switchWorkspaceViaSidebar({ page, ...target });
await test.step("Command Center rows draw each icon form", async () => {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("icon panel");
for (const title of ["Lucide icon panel", "SVG icon panel", "Component icon panel"]) {
await expect(
panel.getByRole("button", { name: `Open ${title}`, exact: true }),
).toBeVisible();
}
await expect(
panel
.getByRole("button", { name: "Open SVG icon panel", exact: true })
.locator(`path[d="${SVG_PATH}"]`),
).toBeVisible();
await expect(
panel
.getByRole("button", { name: "Open Component icon panel", exact: true })
.getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "command-center-icons");
await page.keyboard.press("Escape");
});
await test.step("workspace panel tabs draw each icon form", async () => {
await runCommand(page, "Open Lucide icon panel");
await runCommand(page, "Open SVG icon panel");
await runCommand(page, "Open Component icon panel");
await expect(pluginTab(page, "lucide").locator("svg")).toBeVisible();
await expect(pluginTab(page, "svg").locator(`path[d="${SVG_PATH}"]`)).toBeVisible();
await expect(
pluginTab(page, "component").getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "workspace-tab-icons");
});
} finally {
try {
await client.removePlugin(PLUGIN_ID);
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await workspace.cleanup();
await client.close();
await rm(directory, { recursive: true, force: true });
}
}
});
});
@@ -28,6 +28,7 @@ import {
closeWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
waitForWorkspaceSetupProgress,
observeSetupPanelTraffic,
} from "../support/helpers/workspace-setup";
interface WorkspaceScriptStarter {
@@ -116,6 +117,45 @@ test.describe("Workspace setup streaming", () => {
}
});
test("setup panel releases details while retained behind another tab and resubscribes when shown", async ({
page,
}) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-membership-", {
paseoConfig: { worktree: { setup: ["echo setup complete"] } },
});
const traffic = observeSetupPanelTraffic(page);
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed",
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "setup-membership",
});
await completed;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await expectSetupTabNotSeeded(page, workspace.id);
await traffic.expectNoDemand();
await expectSetupPanel(page);
await traffic.expectSubscribed(workspace.id, 1);
await expect(page.getByTestId("workspace-setup-log").first()).toContainText("setup complete");
await clickNewChat(page);
await expectComposerVisible(page);
await traffic.expectReleased(0);
await expectSetupPanel(page);
await traffic.expectSubscribed(workspace.id, 2);
await closeSetupTab(page, workspace.id);
await traffic.expectReleased(1);
} finally {
await client.close();
await repo.cleanup();
}
});
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-success-", {
@@ -0,0 +1,145 @@
// Run against an Android dev client serving this checkout, with a real software keyboard.
// node packages/app/e2e/mobile/appearance-keyboard/android.mjs <session> <metro-url> <server-id> [app-id]
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, metroUrl, serverId, appId = "sh.paseo.debug"] = process.argv;
assert(
session && metroUrl && serverId,
"Pass the agent-device session, Metro URL and connected host ID",
);
const artifacts = resolve(".dev/agent-device-artifacts/appearance-keyboard-android");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function evaluate(expression) {
return ad("cdp", "runtime", "eval", "--expr", expression, "--json");
}
// Read the mounted app's public React Native APIs through Metro. Accessibility
// snapshots with Gboard open can block while waiting for an idle hierarchy.
const reactNative = `Array.from(__r.getModules().values()).find(
m => m.verboseName?.endsWith("/react-native/index.js")
).publicModule.exports`;
function measureFocusedInput() {
evaluate(`(() => {
const rn = ${reactNative};
globalThis.__appearanceKeyboardMeasurement = null;
rn.TextInput.State.currentlyFocusedInput()?.measureInWindow((x, y, width, height) => {
globalThis.__appearanceKeyboardMeasurement = JSON.stringify({
x, y, width, height,
statusBarHeight: rn.StatusBar.currentHeight,
scale: rn.PixelRatio.get(),
keyboard: rn.Keyboard.metrics(),
});
});
})()`);
const value = JSON.parse(evaluate("globalThis.__appearanceKeyboardMeasurement"));
return value ? JSON.parse(value) : null;
}
async function waitForFocusedInput() {
let measurement;
let previous;
let stable = 0;
const deadline = Date.now() + 30_000;
do {
measurement = measureFocusedInput();
const current = measurement?.keyboard
? [measurement.y, measurement.height, measurement.keyboard.screenY].map(Math.round).join(",")
: null;
stable = measurement?.keyboard && current === previous ? stable + 1 : 0;
previous = current;
if (stable >= 3) break;
await delay(150);
} while (Date.now() < deadline);
assert(stable >= 3, `Focused input and keyboard must settle: ${JSON.stringify(measurement)}`);
return measurement;
}
ad("open", appId, "--platform", "android", "--no-test-ime");
const targets = ad("cdp", "target", "list", "--url", metroUrl)
.split("\n")
.filter((line) => line.includes(appId));
assert.equal(targets.length, 1, "Connect only the test app to this Metro server");
ad("cdp", "target", "select", targets[0].split(/\s+/)[0]);
try {
evaluate(`(${reactNative}).Keyboard.dismiss()`);
ad("open", appId, "paseo://settings", "--platform", "android", "--no-test-ime");
ad("wait", "text", "Appearance", "5000");
ad("open", appId, "paseo://settings/appearance", "--platform", "android", "--no-test-ime");
ad("wait", "text", "Code size", "5000");
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
const numericFields = snapshot.data.nodes.filter(
(node) => node.type === "android.widget.EditText" && /^\d+$/.test(node.value ?? ""),
);
assert.equal(numericFields.length, 3, "All three font sizes must be visible without scrolling");
ad("screenshot", resolve(artifacts, "before-keyboard.png"));
// Appearance orders Interface size, Content size, then Code size.
ad("press", `@${numericFields[2].ref}`);
const measurement = await waitForFocusedInput();
// Android measureInWindow excludes the status bar, Keyboard.metrics uses screen coordinates.
const inputBottom = measurement.y + measurement.height + measurement.statusBarHeight;
const keyboardTop = measurement.keyboard.screenY;
console.log(JSON.stringify({ inputBottom, keyboardTop, measurement }));
ad("screenshot", resolve(artifacts, "keyboard.png"));
assert(
inputBottom <= keyboardTop + 1 / measurement.scale,
"Code size must be fully visible above the number keyboard (within one physical pixel)",
);
console.log("PASS: focusing Code size reveals the whole field above the keyboard");
evaluate(`(${reactNative}).Keyboard.dismiss()`);
ad(
"open",
appId,
`paseo://settings/hosts/${encodeURIComponent(serverId)}/plugins`,
"--platform",
"android",
"--no-test-ime",
);
ad("wait", "text", "Plugin source", "5000");
const plugins = JSON.parse(ad("snapshot", "-i", "--json"));
const source = plugins.data.nodes.find((node) => node.type === "android.widget.EditText");
assert(source, "The connected host must support installing plugins from source");
ad("press", `@${source.ref}`);
ad("type", "qa-source");
const pluginMeasurement = await waitForFocusedInput();
// A FormTextInput draws its border and padding on the native input's parent.
// Measure that visible frame, rather than accepting only the text as visible.
const hierarchy = JSON.parse(ad("snapshot", "--json")).data.nodes;
const focused = hierarchy.find((node) => node.type === "android.widget.EditText" && node.focused);
assert(focused, "Plugin source must remain focused after typing");
assert.equal(focused.value, "qa-source", "Typed source must remain in the focused field");
const frame = hierarchy.find((node) => node.index === focused.parentIndex);
assert(frame, "The source input's visible frame must be present in the native hierarchy");
assert(
frame.rect.height > focused.rect.height,
"Measure the surrounding frame, including padding",
);
const frameBottom = frame.rect.y + frame.rect.height;
const pluginKeyboardTop = pluginMeasurement.keyboard.screenY * pluginMeasurement.scale;
console.log(JSON.stringify({ frameBottom, keyboardTop: pluginKeyboardTop, frame: frame.rect }));
ad("screenshot", resolve(artifacts, "plugin-source-keyboard.png"));
assert(
frameBottom <= pluginKeyboardTop + 1,
"Plugin source frame must be fully visible above the keyboard (within one physical pixel)",
);
console.log("PASS: focusing Plugin source reveals the whole field frame above the keyboard");
} finally {
evaluate(`(${reactNative}).Keyboard.dismiss()`);
evaluate("delete globalThis.__appearanceKeyboardMeasurement");
}
@@ -1,117 +0,0 @@
import { expect, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
export async function openConnectedHosts(page: Page) {
await gotoAppShell(page);
await openSettings(page);
await openHostSection(page, getServerId(), "host");
await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible();
}
export async function addConnectedHost(page: Page, connection: string, label: string) {
await page.getByRole("button", { name: "Add host", exact: true }).click();
await page.getByRole("textbox", { name: "Connection string", exact: true }).fill(connection);
await page.getByRole("textbox", { name: "Host name", exact: true }).fill(label);
await page.getByRole("textbox", { name: "Metadata", exact: true }).fill('{"team":"infra"}');
await saveConnectedHost(page);
}
export async function saveConnectedHost(page: Page) {
await page.getByRole("button", { name: "Save host", exact: true }).click();
}
export async function expectConnectionError(page: Page) {
await expect(page.getByText("Invalid host connection.", { exact: false })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Connection string", exact: true })).toBeVisible();
}
export async function correctConnection(page: Page, value: string) {
await page.getByRole("textbox", { name: "Connection string", exact: true }).fill(value);
await saveConnectedHost(page);
}
export async function inspectConnectedHost(page: Page, label: string) {
await expect(
page.getByText(/^(connecting|offline|error|online|disabled) · (Enabled|Disabled) · User$/),
).toBeVisible();
await page.getByRole("button", { name: `Details for ${label}`, exact: true }).click();
await expect(page.getByText("Connected host details", { exact: true })).toBeVisible();
}
export async function expectSavedHostDetails(page: Page) {
await expect(page.getByText('{"team":"infra"}', { exact: true })).toBeVisible();
await expect(page.getByText("Never", { exact: true })).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Replacement connection", exact: true }),
).toHaveValue("");
}
export async function disableConnectedHost(page: Page) {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Disable", exact: true }).click();
await expect(page.getByText(/disabled · Disabled/)).toBeVisible();
}
export async function enableConnectedHost(page: Page) {
await page.getByRole("button", { name: "Enable", exact: true }).click();
await expect(page.getByRole("button", { name: "Disable", exact: true })).toBeVisible();
}
export async function removeConnectedHost(page: Page, label: string) {
await inspectConnectedHost(page, label);
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Remove host", exact: true }).click();
await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible();
}
export async function openCompactConnectedHosts(page: Page) {
await gotoAppShell(page);
await openCompactSettings(page, new URL(page.url()).pathname);
await openHostSection(page, getServerId(), "host");
await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible();
}
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./isolated-host-daemon";
import { addDirectHostFromSettings, selectSettingsHost } from "./settings";
export async function openRestartableConnectedHosts(page: Page): Promise<IsolatedHostDaemon> {
const host = await startIsolatedHostDaemon("srv_connected_host_draft");
await gotoAppShell(page);
await openSettings(page);
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: host.port });
await selectSettingsHost(page, host.serverId);
await openHostSection(page, host.serverId, "host");
await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible();
return host;
}
export async function beginConnectedHostDraft(page: Page) {
await page.getByRole("button", { name: "Add host", exact: true }).click();
await page.getByRole("textbox", { name: "Host name", exact: true }).fill("Retained draft");
await page
.getByRole("textbox", { name: "Connection string", exact: true })
.fill("tcp://127.0.0.1:1?password=draft-secret");
}
export async function reconnectManagingHost(host: IsolatedHostDaemon) {
await host.restart();
}
export async function expectConnectedHostDraftRetained(page: Page) {
await expect(page.getByRole("textbox", { name: "Host name", exact: true })).toHaveValue(
"Retained draft",
{ timeout: 30000 },
);
await expect(page.getByRole("textbox", { name: "Connection string", exact: true })).toHaveValue(
"tcp://127.0.0.1:1?password=draft-secret",
);
}
export async function editConnectedHost(page: Page) {
await page.getByRole("switch", { name: "Replace destination identity", exact: true }).click();
await saveConnectedHost(page);
await expect(
page.getByText("Identity replacement requires a replacement connection", { exact: true }),
).toBeVisible();
await page
.getByRole("textbox", { name: "Replacement connection", exact: true })
.fill("tcp://127.0.0.1:1?password=replacement-secret");
await page.getByRole("textbox", { name: "Host name", exact: true }).fill("Renamed box");
await page.getByRole("textbox", { name: "Metadata", exact: true }).fill('{"team":"tools"}');
await saveConnectedHost(page);
await inspectConnectedHost(page, "Renamed box");
await expect(page.getByText('{"team":"tools"}', { exact: true })).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Replacement connection", exact: true }),
).toHaveValue("");
}
@@ -24,6 +24,7 @@ interface E2EDaemonClientConfig {
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
capabilities?: Record<string, unknown>;
}
function resolveDaemonWsUrl(port?: number): string {
@@ -34,6 +35,7 @@ export interface ConnectDaemonClientOptions {
clientIdPrefix: string;
appVersion?: string;
port?: number;
capabilities?: Record<string, unknown>;
}
/**
@@ -51,6 +53,7 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
clientType: "cli",
appVersion: options.appVersion ?? loadAppVersion(),
webSocketFactory: createNodeWebSocketFactory(),
capabilities: options.capabilities,
});
await client.connect();
return client;
@@ -0,0 +1,77 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { expectFileTabOpen, openFileFromExplorer } from "./file-explorer";
/** A directory plugin whose "Rich Markdown" file panel claims `.md`, next to the built-in. */
export const MARKDOWN_PANEL_PLUGIN_ID = "file-panel-picker-e2e";
export const MARKDOWN_PANEL_ID = `plugin:${MARKDOWN_PANEL_PLUGIN_ID}/rich-markdown`;
export const BUILTIN_MARKDOWN_OPTION = "Markdown — Paseo";
export const PLUGIN_MARKDOWN_OPTION = `Rich Markdown — ${MARKDOWN_PANEL_PLUGIN_ID}`;
export const SOURCE_OPTION = "Source — Paseo";
const SOURCE = `import React from "react";
import { Text, View } from "react-native";
function RichMarkdown({ file }) {
return <View><Text>Rich Markdown plugin: {file.name}</Text></View>;
}
export default function contribute(client) {
client.addFilePanel({ id: "rich-markdown", title: "Rich Markdown", icon: "FileType",
files: { extensions: ["md"] }, document: "text", Component: RichMarkdown });
return () => {};
}`;
/** Enables plugins on the test daemon and installs the plugin; `uninstall` restores both. */
export async function installMarkdownPanelPlugin(): Promise<{ uninstall(): Promise<void> }> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-markdown-file-panel-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
const uninstall = async () => {
await client.removePlugin(MARKDOWN_PANEL_PLUGIN_ID);
await client.patchDaemonConfig({
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
});
await client.close();
await rm(directory, { recursive: true, force: true });
};
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: MARKDOWN_PANEL_PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
} catch (error) {
await uninstall();
throw error;
}
return { uninstall };
}
export function filePanelPicker(page: Page) {
return page.getByText("Open .md files with", { exact: true });
}
export async function openMarkdown(page: Page, name: string): Promise<void> {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
export async function expectBuiltinMarkdown(page: Page, heading: string): Promise<void> {
await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();
}
export async function expectPluginMarkdown(page: Page, name: string): Promise<void> {
await expect(page.getByText(`Rich Markdown plugin: ${name}`, { exact: true })).toBeVisible();
}
export async function storedFilePanelAssociations(page: Page): Promise<unknown> {
return page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem("@paseo:app-settings") ?? "{}");
return settings.filePanelAssociations ?? {};
});
}
@@ -24,6 +24,7 @@ const SECTION_LABELS = {
appearance: "Appearance",
sidebar: "Sidebar",
editor: "Editor",
files: "Files",
terminal: "Terminal",
browser: "Browser",
shortcuts: "Shortcuts",
@@ -35,6 +35,8 @@ export type { WorkspaceSetupDaemonClient };
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const client = await connectDaemonClient<WorkspaceSetupDaemonClient>({
clientIdPrefix: "workspace-setup",
// COMPAT(workspaceSetupObservation): legacy streaming coverage; remove after 2027-04-19.
capabilities: { workspace_setup_observation: false },
});
// Establish demand before a caller can launch setup. Client close releases it.
try {
@@ -326,3 +328,51 @@ export async function waitForWorkspaceSetupProgress(
});
});
}
/** Observe the browser's actual setup membership without requesting any output. */
export function observeSetupPanelTraffic(page: Page) {
const subscriptions: Array<{ workspaceId: string; subscriptionId: string }> = [];
const releases = new Set<string>();
const requests: string[] = [];
let legacySetupDemand = false;
const outputWorkspaces: string[] = [];
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const frame = JSON.parse(String(payload));
if (frame.type !== "session") return;
if (frame.message?.type === "subscription.release.request")
releases.add(frame.message.subscriptionId);
if (frame.message?.type === "workspace.setup.subscribe.request")
requests.push(frame.message.workspaceId);
if (
frame.message?.type === "session.events.set_subscription.request" &&
frame.message.events.includes("workspace_setup_progress")
)
legacySetupDemand = true;
});
socket.on("framereceived", ({ payload }) => {
const frame = JSON.parse(String(payload));
if (frame.type !== "session") return;
if (frame.message?.type === "workspace.setup.subscribe.response")
subscriptions.push(frame.message.payload);
if (frame.message?.type === "workspace_setup_progress")
outputWorkspaces.push(frame.message.payload.workspaceId);
});
});
return {
async expectNoDemand() {
expect(requests).toEqual([]);
expect(legacySetupDemand).toBe(false);
expect(subscriptions).toEqual([]);
expect(outputWorkspaces).toEqual([]);
},
async expectSubscribed(workspaceId: string, count: number) {
await expect.poll(() => subscriptions.length).toBe(count);
expect(subscriptions[count - 1].workspaceId).toBe(workspaceId);
expect(requests).toEqual(Array(count).fill(workspaceId));
},
async expectReleased(index: number) {
await expect.poll(() => releases.has(subscriptions[index].subscriptionId)).toBe(true);
},
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -1,4 +1,4 @@
import type { GitAction, GitActions } from "@/git/policy";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type {
WorkspacePanelPlacement,
@@ -581,9 +581,7 @@ export function buildWorkspaceCommandCenterContributions(
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
];
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
for (const [index, action] of actionsWithoutPrimary(source.gitActions).entries()) {
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
@@ -41,7 +41,7 @@ import {
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { shouldShowWorkspaceSetup } from "@/stores/workspace-setup-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import {
@@ -188,6 +188,7 @@ export function useWorkspaceCommandCenterActions(): void {
workspaceDirectory: workspace.workspaceDirectory ?? null,
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
pinnedAt: workspace.pinnedAt ?? null,
setupStatus: workspace.setupStatus ?? null,
labels: workspace.labels ?? [],
}));
const cwd = useWorkspaceDirectory(serverId, workspaceId);
@@ -195,12 +196,10 @@ export function useWorkspaceCommandCenterActions(): void {
const isPinned = fields?.pinnedAt != null;
const isCompact = useIsCompactFormFactor();
const canPin = useHostFeature(serverId, "workspacePinning");
const persistenceKey =
serverId && fields
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id })
: null;
const canShowSetup = useWorkspaceSetupStore((state) =>
shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null),
const supportsSetupObservation = useHostFeature(serverId, "workspaceSetupObservation");
const canShowSetup = shouldShowWorkspaceSetup(
fields?.setupStatus ?? null,
supportsSetupObservation,
);
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions, isGit } = useGitActions({
@@ -1,40 +0,0 @@
import { describe, expect, it } from "vitest";
import { filePreviewRenderKind, isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
describe("isRenderedMarkdownFile", () => {
it("detects .md files", () => {
expect(isRenderedMarkdownFile("README.md")).toBe(true);
expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true);
});
it("detects .markdown files", () => {
expect(isRenderedMarkdownFile("notes.markdown")).toBe(true);
expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true);
});
it("does not treat .mdx files as rendered markdown", () => {
expect(isRenderedMarkdownFile("page.mdx")).toBe(false);
});
it("does not treat other text files as rendered markdown", () => {
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
expect(isRenderedMarkdownFile("plan.html")).toBe(false);
});
});
describe("filePreviewRenderKind", () => {
it("maps each renderable extension to its kind", () => {
expect(filePreviewRenderKind("README.md")).toBe("markdown");
expect(filePreviewRenderKind("notes.markdown")).toBe("markdown");
expect(filePreviewRenderKind("plan.html")).toBe("html");
expect(filePreviewRenderKind("docs/PLAN.HTML")).toBe("html");
expect(filePreviewRenderKind("plan.htm")).toBe("html");
});
it("returns null for files without a rendered preview", () => {
expect(filePreviewRenderKind("src/index.ts")).toBe(null);
expect(filePreviewRenderKind("page.mdx")).toBe(null);
expect(filePreviewRenderKind("index.html.erb")).toBe(null);
});
});
@@ -1,17 +0,0 @@
export type FilePreviewRenderKind = "markdown" | "html";
export function isRenderedMarkdownFile(filePath: string): boolean {
const normalizedPath = filePath.trim().toLowerCase();
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
}
function isRenderedHtmlFile(filePath: string): boolean {
const normalizedPath = filePath.trim().toLowerCase();
return normalizedPath.endsWith(".html") || normalizedPath.endsWith(".htm");
}
export function filePreviewRenderKind(filePath: string): FilePreviewRenderKind | null {
if (isRenderedMarkdownFile(filePath)) return "markdown";
if (isRenderedHtmlFile(filePath)) return "html";
return null;
}
@@ -1,10 +1,14 @@
import { useMemo, type ReactNode } from "react";
import { ScrollView, Text, View } from "react-native";
import { Platform, ScrollView, Text, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { MenuHeader } from "@/components/headers/menu-header";
import { useIsCompactFormFactor } from "@/constants/layout";
import { SPACING } from "@/styles/theme";
const PageScrollView = Platform.OS === "android" ? KeyboardAwareScrollView : ScrollView;
interface PageLayoutProps {
title?: string;
@@ -42,7 +46,13 @@ export function PageLayout({
) : (
<MenuHeader borderless />
)}
<ScrollView style={styles.scroll} contentContainerStyle={scrollContentStyle} testID={testID}>
<PageScrollView
// Leave room for field borders and padding around the focused native input.
{...(Platform.OS === "android" ? { bottomOffset: SPACING[4] } : {})}
style={styles.scroll}
contentContainerStyle={scrollContentStyle}
testID={testID}
>
<View style={styles.content}>
{showTitle ? (
<View style={styles.titleRow} testID={titleTestID}>
@@ -54,7 +64,7 @@ export function PageLayout({
) : null}
{children}
</View>
</ScrollView>
</PageScrollView>
</View>
);
}
-315
View File
@@ -1,315 +0,0 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { createPaseoApi, type HostSummary, type HostDirectory } from "@getpaseo/client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
SettingsCard,
SettingsRow,
SettingsInput,
SettingsSwitch,
SettingsSection,
} from "@/components/settings";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { ConnectedHostsModel, type Editor } from "./model";
export function ConnectedHostsSection({ serverId }: { serverId: string }) {
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
return (
<SettingsSection title="Hosts connected to this daemon">
{client ? (
<ConnectedHosts key={serverId} client={client} connected={connected} />
) : (
<SettingsCard>
<SettingsRow label="Connect to this daemon to manage its hosts" />
</SettingsCard>
)}
</SettingsSection>
);
}
function useModel(client: DaemonClient, connected: boolean) {
const [model] = useState(() => new ConnectedHostsModel(createPaseoApi(client)));
useEffect(() => {
model.setConnected(connected);
}, [model, connected]);
useEffect(() => model.close, [model]);
return model;
}
function ConnectedHosts({ client, connected }: { client: DaemonClient; connected: boolean }) {
const model = useModel(client, connected);
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
const info = client.getLastServerInfoMessage();
const canManage = info?.permissions?.includes("tunnel.manage") === true;
const directory = state.load.status === "loaded" ? state.load.directory : null;
const editor = state.editor;
return (
<View style={styles.section}>
{state.load.status === "loading" ? (
<SettingsCard>
<SettingsRow label="Loading connected hosts…" />
</SettingsCard>
) : null}
{state.load.status === "error" ? (
<SettingsCard>
<SettingsRow label="Could not load connected hosts" error={state.load.error}>
<Button variant="outline" size="sm" onPress={model.start}>
Retry
</Button>
</SettingsRow>
</SettingsCard>
) : null}
{directory ? (
<HostList
directory={directory}
model={model}
canManage={canManage}
pending={state.pending !== null}
/>
) : null}
{directory && canManage && !editor ? (
<Button variant="outline" size="sm" disabled={state.pending !== null} onPress={model.add}>
Add host
</Button>
) : null}
{editor ? (
<HostEditor
key={editor.key}
editor={editor}
directory={directory}
model={model}
canManage={canManage}
manageSources={info?.permissions?.includes("access.manage") === true}
sourceId={info?.sourceId}
pending={state.pending !== null}
/>
) : null}
{state.error ? (
<SettingsCard>
<SettingsRow label="Host change failed" error={state.error}>
<Button variant="ghost" size="sm" onPress={model.dismissError}>
Dismiss
</Button>
</SettingsRow>
</SettingsCard>
) : null}
{state.pending ? (
<Text accessibilityLiveRegion="polite" style={settingsStyles.rowHint}>
Saving host change…
</Text>
) : null}
</View>
);
}
function HostList({
directory,
model,
canManage,
pending,
}: {
directory: HostDirectory;
model: ConnectedHostsModel;
canManage: boolean;
pending: boolean;
}) {
return (
<SettingsCard>
{directory.hosts.length === 0 ? (
<SettingsRow label="No hosts connected to this daemon" />
) : (
directory.hosts.map((host) => (
<HostRow
key={host.registrationId}
host={host}
model={model}
canManage={canManage}
pending={pending}
/>
))
)}
</SettingsCard>
);
}
function HostRow({
host,
model,
canManage,
pending,
}: {
host: HostSummary;
model: ConnectedHostsModel;
canManage: boolean;
pending: boolean;
}) {
const edit = useCallback(() => model.edit(host), [host, model]);
const toggle = useCallback(() => void model.setEnabled(host), [host, model]);
return (
<SettingsRow
label={host.label ?? host.serverId ?? host.registrationId}
hint={`${host.status} · ${host.enabled ? "Enabled" : "Disabled"} · ${sourceLabel(host.source.id)}`}
>
<View style={styles.actions}>
<Button
variant="outline"
size="sm"
disabled={pending}
onPress={edit}
accessibilityLabel={`Details for ${host.label ?? host.registrationId}`}
>
Details
</Button>
{canManage ? (
<Button variant="outline" size="sm" disabled={pending} onPress={toggle}>
{host.enabled ? "Disable" : "Enable"}
</Button>
) : null}
</View>
</SettingsRow>
);
}
function HostDetails({ host }: { host: HostSummary }) {
return (
<SettingsCard>
<SettingsRow label="Registration" hint={host.registrationId} />
<SettingsRow label="Server ID" hint={host.serverId ?? "Not discovered"} />
<SettingsRow label="Connection" hint={host.targetDescription} />
<SettingsRow label="Metadata" hint={JSON.stringify(host.metadata)} />
<SettingsRow label="Status" hint={host.status} />
<SettingsRow label="Source" hint={sourceLabel(host.source.id)} />
<SettingsRow label="Source identity" hint={host.source.id} />
<SettingsRow label="Source key" hint={host.source.key} />
<SettingsRow label="Last online" hint={host.lastOnlineAt ?? "Never"} />
<SettingsRow label="Next retry" hint={host.nextRetryAt ?? "None scheduled"} />
{host.error ? <SettingsRow label={host.error.code} error={host.error.message} /> : null}
</SettingsCard>
);
}
function HostEditor({
editor,
directory,
model,
canManage,
manageSources,
sourceId,
pending,
}: {
editor: Editor;
directory: HostDirectory | null;
model: ConnectedHostsModel;
canManage: boolean;
manageSources: boolean;
sourceId: string | undefined;
pending: boolean;
}) {
const compact = useIsCompactFormFactor();
const detail =
directory?.hosts.find((host) => host.registrationId === editor.host?.registrationId) ??
editor.host;
const editSource = !detail || manageSources || detail.source.id === sourceId;
const remove = useCallback(async () => {
if (!detail) return;
const confirmed = await confirmDialog({
title: "Remove connected host?",
message:
detail.source.id === "owner"
? "This removes the saved connection. The host's agents and workspaces keep running."
: `Managed by ${detail.source.id}. Its source can add it again. Disable the host to keep access off.`,
confirmLabel: "Remove",
destructive: true,
});
if (confirmed) await model.remove(detail);
}, [detail, model]);
return (
<View style={styles.section}>
<Text style={settingsStyles.rowTitle}>{detail ? "Connected host details" : "Add host"}</Text>
{detail ? <HostDetails host={detail} /> : null}
{canManage ? (
<SettingsCard>
<SettingsInput
label="Host name"
initialValue={editor.label}
onChangeText={model.setLabel}
disabled={pending}
/>
{editSource ? (
<SettingsInput
label={detail ? "Replacement connection" : "Connection string"}
initialValue={editor.connection}
onChangeText={model.setConnection}
secureTextEntry
disabled={pending}
/>
) : null}
{detail && editSource ? (
<SettingsSwitch
label="Replace destination identity"
value={editor.replaceIdentity}
onValueChange={model.setReplaceIdentity}
disabled={pending}
/>
) : null}
</SettingsCard>
) : null}
{canManage && editSource ? (
<Field label="Metadata">
<FormTextInput
accessibilityLabel="Metadata"
initialValue={editor.metadata}
onChangeText={model.setMetadata}
multiline
editable={!pending}
size={compact ? "md" : "sm"}
/>
</Field>
) : null}
<View style={styles.actions}>
{canManage ? (
<Button
variant="default"
size="sm"
loading={pending}
disabled={pending}
onPress={model.save}
>
Save host
</Button>
) : null}
<Button variant="ghost" size="sm" disabled={pending} onPress={model.cancel}>
Cancel
</Button>
{canManage && detail && editSource ? (
<Button variant="outline" size="sm" disabled={pending} onPress={remove}>
Remove host
</Button>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
section: { gap: theme.spacing[3] },
actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] },
}));
function sourceLabel(sourceId: string): string {
if (sourceId === "owner") return "User";
if (sourceId.startsWith("plugin:")) return `Plugin: ${sourceId.slice("plugin:".length)}`;
if (sourceId.startsWith("route:")) {
const components = sourceId.split(":");
let producer: string;
try {
producer = decodeURIComponent(components[3] ?? "");
} catch {
return "User (via host)";
}
return producer.startsWith("plugin:")
? `Plugin: ${producer.slice("plugin:".length)} (via host)`
: "User (via host)";
}
return "User";
}
-192
View File
@@ -1,192 +0,0 @@
import type {
PaseoApi,
HostSummary,
HostUpdate,
HostDirectory,
OwnedSubscription,
} from "@getpaseo/client";
export interface Editor {
key: number;
host: HostSummary | null;
connection: string;
label: string;
metadata: string;
replaceIdentity: boolean;
}
type Load =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; directory: HostDirectory };
interface State {
load: Load;
editor: Editor | null;
pending: string | null;
error: string | null;
}
/** Owns fetch/observation, form sequencing and mutation state outside React. */
export class ConnectedHostsModel {
private state: State = { load: { status: "loading" }, editor: null, pending: null, error: null };
private readonly listeners = new Set<() => void>();
private observation?: OwnedSubscription<HostDirectory>;
private generation = 0;
private editorKey = 0;
private connected = false;
setConnected = (connected: boolean) => {
this.connected = connected;
if (connected && !this.observation) this.start();
else if (!connected)
this.publish({
load: {
status: "error",
error:
"Connection to this daemon was lost. Reconnect to continue; your edits are retained.",
},
});
};
constructor(private readonly api: PaseoApi) {}
getState = () => this.state;
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
private publish(patch: Partial<State>) {
this.state = { ...this.state, ...patch };
for (const listener of this.listeners) listener();
}
start = () => {
if (!this.connected) {
this.publish({
load: { status: "error", error: "Connect to this daemon to load its hosts." },
});
return;
}
const generation = ++this.generation;
void this.observation?.release();
this.publish({ load: { status: "loading" } });
try {
const observation = this.api.hosts.observe();
this.observation = observation;
observation.subscribe({
snapshot: (directory) => {
if (generation === this.generation)
this.publish({ load: { status: "loaded", directory } });
},
update: (message) => {
if (generation === this.generation && message.type === "hosts.changed")
this.publish({ load: { status: "loaded", directory: message.payload.directory } });
},
error: (error) => {
if (generation === this.generation)
this.publish({ load: { status: "error", error: describe(error) } });
},
});
void observation.ready.catch((error) => {
if (generation === this.generation)
this.publish({ load: { status: "error", error: describe(error) } });
});
} catch (error) {
this.publish({ load: { status: "error", error: describe(error) } });
}
};
close = () => {
this.generation++;
void this.observation?.release();
void this.api.dispose();
this.listeners.clear();
};
edit = (host: HostSummary | null) => {
if (this.state.pending) return;
this.publish({
error: null,
editor: {
key: ++this.editorKey,
host,
connection: "",
label: host?.label ?? "",
metadata: JSON.stringify(host?.metadata ?? {}, null, 2),
replaceIdentity: false,
},
});
};
add = () => this.edit(null);
setLabel = (label: string) => this.set({ label });
setConnection = (connection: string) => this.set({ connection });
setMetadata = (metadata: string) => this.set({ metadata });
setReplaceIdentity = (replaceIdentity: boolean) => this.set({ replaceIdentity });
set = (patch: Partial<Omit<Editor, "host" | "key">>) => {
if (this.state.editor) this.publish({ editor: { ...this.state.editor, ...patch } });
};
cancel = () => {
if (!this.state.pending) this.publish({ editor: null, error: null });
};
dismissError = () => this.publish({ error: null });
private async mutate(id: string, operation: () => Promise<unknown>): Promise<void> {
if (this.state.pending) return;
this.publish({ pending: id, error: null });
try {
await operation();
this.publish({
load: { status: "loaded", directory: await this.api.hosts.list() },
editor: null,
});
} catch (error) {
this.publish({ error: describe(error) });
} finally {
this.publish({ pending: null });
}
}
save = () =>
this.mutate("save", async () => {
const editor = this.state.editor;
if (!editor) return;
let metadata: Record<string, string>;
try {
const value: unknown = JSON.parse(editor.metadata);
if (
!value ||
typeof value !== "object" ||
Array.isArray(value) ||
Object.values(value).some((entry) => typeof entry !== "string")
)
throw new Error("invalid");
metadata = value as Record<string, string>;
} catch {
throw new Error("Metadata must be a JSON object with string values.");
}
if (editor.host) {
const patch: HostUpdate = {
label: editor.label.trim() || null,
replaceIdentity: editor.replaceIdentity,
};
if (JSON.stringify(metadata) !== JSON.stringify(editor.host.metadata))
patch.metadata = metadata;
if (editor.connection.trim()) {
patch.connection = editor.connection.trim();
}
await this.api.hosts.update(editor.host.registrationId, patch);
} else
await this.api.hosts.add({
connection: editor.connection.trim(),
label: editor.label.trim() || undefined,
metadata,
});
});
setEnabled = (host: HostSummary) =>
this.mutate(host.registrationId, () =>
this.api.hosts.update(host.registrationId, { enabled: !host.enabled }),
);
remove = (host: HostSummary) =>
this.mutate(host.registrationId, () => this.api.hosts.remove(host.registrationId));
}
function describe(error: unknown): string {
if (
error &&
typeof error === "object" &&
"userMessage" in error &&
typeof error.userMessage === "string"
)
return error.userMessage;
return error instanceof Error ? error.message : "Host operation failed. Retry when connected.";
}
@@ -36,7 +36,6 @@ import {
useSessionStore,
type SessionState,
} from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
import {
@@ -168,11 +167,6 @@ function resolveAgentAttentionNotification(
});
}
type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
>["payload"];
function notifyVoiceAbortFailure(
data: Extract<SessionOutboundMessage, { type: "activity_log" }>["payload"],
notifyError: (message: string) => void,
@@ -225,7 +219,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
// Track focused agent for heartbeat
const focusedAgentId = useSessionStore(
@@ -365,13 +358,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
const applyWorkspaceSetupProgress = useCallback(
(payload: WorkspaceSetupProgressPayload) => {
upsertWorkspaceSetupProgress({ serverId, payload });
},
[serverId, upsertWorkspaceSetupProgress],
);
useEffect(() => {
const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents);
const onStream = (message: SessionOutboundMessage) => {
@@ -508,7 +494,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
"agent_permission_resolved",
"agent.provider_subagents.update",
"checkout_status_update",
"workspace_setup_progress",
"status.server_info",
],
{ notifications: true },
@@ -547,11 +532,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message });
});
const unsubWorkspaceSetupProgress = onFeed("workspace_setup_progress", (message) => {
if (message.type !== "workspace_setup_progress") return;
applyWorkspaceSetupProgress(message.payload);
});
const unsubStatus = onFeed("status", (message) => {
if (message.type !== "status") return;
const serverInfo = parseServerInfoStatusPayload(message.payload);
@@ -710,7 +690,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubProviderSubagentUpdate();
unsubAgentAttention();
unsubCheckoutStatusUpdate();
unsubWorkspaceSetupProgress();
unsubStatus();
unsubPermissionRequest();
unsubPermissionResolved();
@@ -732,7 +711,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgents,
setPendingPermissions,
notifyAgentAttention,
applyWorkspaceSetupProgress,
updateSessionServerInfo,
toast,
voiceRuntime,
+41 -42
View File
@@ -1,9 +1,10 @@
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { FilePanelBarMenu, FilePanelSwitcher, type FilePanelChoice } from "@/file-panels";
import type { Theme } from "@/styles/theme";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { FileEditorStatus } from "./editor/model";
@@ -11,34 +12,26 @@ import type { FileEditorStatus } from "./editor/model";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/**
* The file tab's bar: the document's status, which the host owns whatever panel shows it, and the
* switcher between the file's panels. `trailing` is the active panel's own status.
*/
export function FilePanelBar({
size,
lineCount,
mode,
onModeChange,
editorStatus,
cursor,
vimMode,
conflict,
choice,
trailing,
}: {
size: number;
lineCount?: number;
mode?: "preview" | "source";
onModeChange?(mode: "preview" | "source"): void;
editorStatus?: FileEditorStatus;
cursor?: { line: number; column: number };
vimMode?: string | null;
conflict?: FileConflictAlertState;
choice: FilePanelChoice;
trailing?: ReactNode;
}) {
const { t } = useTranslation();
const previewModes = [
{
value: "preview" as const,
label: t("panels.file.editor.preview"),
testID: "file-mode-preview",
},
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
];
return (
<View style={styles.chrome}>
<PaneContentToolbar testID="file-panel-bar">
@@ -46,6 +39,7 @@ export function FilePanelBar({
<View style={styles.metadata}>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{formatFileSize(size)}
@@ -53,6 +47,7 @@ export function FilePanelBar({
{lineCount !== undefined ? (
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
>
{t("panels.file.editor.lines", { count: lineCount })}
@@ -82,32 +77,10 @@ export function FilePanelBar({
{editorStatus === "error" ? (
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
) : null}
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
{cursor ? (
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
>
Ln {cursor.line}, Col {cursor.column}
</Text>
) : null}
{trailing}
</View>
{mode && onModeChange ? (
<SegmentedControl
size="xs"
value={mode}
onValueChange={onModeChange}
testID="file-preview-mode"
options={previewModes}
/>
) : null}
<FilePanelSwitcher choice={choice} />
<FilePanelBarMenu choice={choice} />
</View>
</PaneContentToolbar>
{conflict ? <FileConflictAlert state={conflict} /> : null}
@@ -115,6 +88,32 @@ export function FilePanelBar({
);
}
/** Source's status in the bar: the vim mode and the cursor position. */
export function FileSourceStatus({
cursor,
vimMode,
}: {
cursor: { line: number; column: number };
vimMode: string | null;
}) {
const { t } = useTranslation();
return (
<>
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
<Text style={styles.whisper} accessibilityLabel={t("panels.file.editor.cursor", cursor)}>
Ln {cursor.line}, Col {cursor.column}
</Text>
</>
);
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
@@ -4,7 +4,6 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
@@ -25,8 +24,12 @@ const wrappingCompartment = new Compartment();
const themeCompartment = new Compartment();
const vimCompartment = new Compartment();
/** Markdown is prose, so its paragraphs wrap; code keeps its lines. */
const WRAPPED_EXTENSIONS = new Set(["md", "markdown"]);
function wrappingForFile(filename: string) {
return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : [];
const extension = filename.split(".").pop()?.toLowerCase() ?? "";
return WRAPPED_EXTENSIONS.has(extension) ? EditorView.lineWrapping : [];
}
export function FileEditorView({
+135 -165
View File
@@ -1,20 +1,19 @@
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ScrollView as RNScrollView, Text, View } from "react-native";
import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
import {
FilePanelPicker,
FilePanelView,
isSourceFilePanel,
useFilePanelChoice,
type FilePanel,
type FilePanelChoice,
} from "@/file-panels";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { getFileNameFromPath } from "@/attachments/utils";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
@@ -27,33 +26,31 @@ import { useAppSettings } from "@/hooks/use-settings";
import { useLiveFile } from "./live-file/hook";
import { useFilePreview } from "./preview-lifecycle/hook";
import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model";
import { FilePanelBar } from "./bar";
import { FileHtmlPreview } from "./html-preview";
import { FileMarkdownPreview } from "./markdown-preview";
import { FilePanelBar, FileSourceStatus } from "./bar";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
import { FileSourceView } from "./source/view";
import type { FileConflictAlertState } from "./conflict-alert";
import type { LiveFileModel } from "./live-file/model";
import type { LiveFileModel, LiveFileSnapshot } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import type { Theme } from "@/styles/theme";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
interface FilePreviewBodyProps {
interface FilePanelBodyProps {
bytes: Uint8Array | null;
setText?: (text: string) => void;
panel: FilePanel | null;
preview: ExplorerFile | null;
mode?: "preview" | "source";
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
documentPreviewUri: string | null;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
@@ -130,28 +127,22 @@ function TooLargeSource({ size }: { size?: number }) {
);
}
function FilePreviewBody({
/** The tab body: the file's panel, Source read-only when the tab cannot edit. */
function FilePanelBody({
bytes,
setText,
panel,
preview,
mode,
isLoading,
isMobile: _isMobile,
location,
navigationRevision,
imagePreviewUri,
documentPreviewUri,
workspaceRoot,
client,
serverId,
}: FilePreviewBodyProps) {
}: FilePanelBodyProps) {
const { t } = useTranslation();
const filePath = location.path;
// A line target means the caller wants to land on that line, so fall back to
// the highlighted source view even for renderable files.
const renderKind =
preview?.kind === "text" && !location.lineStart && mode !== "source"
? filePreviewRenderKind(filePath)
: null;
const previewScrollRef = useRef<RNScrollView>(null);
if (isLoading && !preview) {
return (
@@ -170,36 +161,15 @@ function FilePreviewBody({
);
}
if (preview.kind === "text") {
if (renderKind === "html") {
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
return (
<View style={styles.previewScrollContainer}>
<FileHtmlPreview html={preview.content ?? ""} testID="file-html-preview" />
</View>
);
}
if (renderKind === "markdown") {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
showsVerticalScrollIndicator
>
<FileMarkdownPreview
source={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
</RNScrollView>
</View>
);
}
if (!panel) {
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
}
if (isSourceFilePanel(panel)) {
return (
<ReadonlySource
preview={preview}
@@ -209,25 +179,21 @@ function FilePreviewBody({
/>
);
}
if (preview.kind === "image") {
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
return <ZoomableImage uri={imagePreviewUri} testID="image-file-preview" />;
}
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
<FilePanelView
bytes={bytes}
panel={panel}
preview={preview}
location={location}
navigationRevision={navigationRevision}
setText={setText}
content={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
documentPreviewUri={documentPreviewUri}
/>
);
}
@@ -243,8 +209,6 @@ export function FilePane({
navigationRevision: number;
}) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
@@ -283,21 +247,31 @@ export function FilePane({
});
const targetKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
const { panel: resolvedPanel, choice } = useFilePanelChoice({
serverId,
targetKey,
path: location.path,
file: readyFile(liveFile.snapshot, targetKey),
presentable: isActive,
lineNavigation: location.lineStart ? navigationRevision : null,
});
const previewLifecycle = useFilePreview({
panel: resolvedPanel,
targetKey,
liveFileSnapshot: liveFile.snapshot,
});
useEffect(() => setPreviewMode("preview"), [targetKey]);
const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle);
const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment);
const isRenderable = isRenderablePreview(preview, location.path);
const {
file: preview,
panel,
bytes,
documentAttachment,
} = resolveFilePreviewLifecycle(previewLifecycle);
const documentPreviewUri = useAttachmentPreviewUrl(documentAttachment);
const editable = isEditableTextFile({
preview,
supportsEditing,
});
const canTogglePreviewMode = isRenderable && !location.lineStart;
const lineCount =
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
const errorMessage = previewLifecycle.status === "error" ? previewLifecycle.message : null;
@@ -307,34 +281,41 @@ export function FilePane({
previewLifecycle.status === "preparing";
return (
<FilePanePresentation
serverId={serverId}
workspaceRoot={normalizedWorkspaceRoot}
client={client}
readTarget={readTarget}
preview={preview}
liveFile={liveFile.model}
onRetryRead={liveFile.refresh}
retryingRead={liveFile.isRetrying}
retryLabel={t("common.actions.retry")}
filename={getFileNameFromPath(location.path) ?? location.path}
previewMode={canTogglePreviewMode ? previewMode : undefined}
onPreviewModeChange={canTogglePreviewMode ? setPreviewMode : undefined}
lineCount={lineCount}
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
errorMessage={errorMessage}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
/>
<>
<FilePanelPicker choice={choice} />
<FilePanePresentation
bytes={bytes}
panel={panel}
choice={choice}
serverId={serverId}
workspaceRoot={normalizedWorkspaceRoot}
client={client}
readTarget={readTarget}
preview={preview}
liveFile={liveFile.model}
onRetryRead={liveFile.refresh}
retryingRead={liveFile.isRetrying}
retryLabel={t("common.actions.retry")}
filename={getFileNameFromPath(location.path) ?? location.path}
lineCount={lineCount}
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
errorMessage={errorMessage}
isLoading={isLoading}
location={location}
navigationRevision={navigationRevision}
documentPreviewUri={documentPreviewUri}
/>
</>
);
}
function isRenderablePreview(preview: ExplorerFile | null, path: string): boolean {
return preview?.kind === "text" && filePreviewRenderKind(path) !== null;
/** The read for `targetKey`; right after a retarget the snapshot still holds the previous file. */
function readyFile(snapshot: LiveFileSnapshot, targetKey: string | null): FileReadResult | null {
const observation = snapshot.observation;
if (observation?.status !== "ready") return null;
const { cwd, path } = observation.version;
return targetKey === `${cwd}:${path}` ? observation.file : null;
}
function isEditableTextFile(input: {
@@ -350,6 +331,9 @@ function isEditableTextFile(input: {
}
function FilePanePresentation({
bytes,
panel,
choice,
serverId,
workspaceRoot,
client,
@@ -360,18 +344,18 @@ function FilePanePresentation({
retryingRead,
retryLabel,
filename,
previewMode,
onPreviewModeChange,
lineCount,
editable,
disconnectedMessage,
errorMessage,
isLoading,
isMobile,
location,
navigationRevision,
imagePreviewUri,
documentPreviewUri,
}: {
bytes: Uint8Array | null;
panel: FilePanel | null;
choice: FilePanelChoice;
serverId: string;
workspaceRoot: string;
client: DaemonClient | null;
@@ -382,17 +366,14 @@ function FilePanePresentation({
retryingRead: boolean;
retryLabel: string;
filename: string;
previewMode?: "preview" | "source";
onPreviewModeChange?: (mode: "preview" | "source") => void;
lineCount?: number;
editable: boolean;
disconnectedMessage: string;
errorMessage: string | null;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
documentPreviewUri: string | null;
}) {
if (!client && readTarget) {
return (
@@ -407,6 +388,10 @@ function FilePanePresentation({
if (editable && client && readTarget && preview?.kind === "text") {
return (
<EditableFilePane
bytes={bytes}
panel={panel}
choice={choice}
documentPreviewUri={documentPreviewUri}
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
serverId={serverId}
workspaceRoot={workspaceRoot}
@@ -418,10 +403,7 @@ function FilePanePresentation({
onRetryRead={onRetryRead}
retryingRead={retryingRead}
filename={filename}
mode={previewMode}
onModeChange={onPreviewModeChange}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
/>
@@ -450,22 +432,15 @@ function FilePanePresentation({
return (
<View style={styles.container} testID="workspace-file-pane">
{preview ? (
<FilePanelBar
size={preview.size}
lineCount={lineCount}
mode={previewMode}
onModeChange={onPreviewModeChange}
/>
) : null}
<FilePreviewBody
{preview ? <FilePanelBar size={preview.size} lineCount={lineCount} choice={choice} /> : null}
<FilePanelBody
bytes={bytes}
panel={panel}
preview={preview}
mode={previewMode}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
documentPreviewUri={documentPreviewUri}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
@@ -475,6 +450,10 @@ function FilePanePresentation({
}
function EditableFilePane({
bytes,
documentPreviewUri,
panel,
choice,
serverId,
workspaceRoot,
client,
@@ -485,13 +464,14 @@ function EditableFilePane({
onRetryRead,
retryingRead,
filename,
mode,
onModeChange,
isLoading,
isMobile,
location,
navigationRevision,
}: {
bytes: Uint8Array | null;
documentPreviewUri: string | null;
panel: FilePanel | null;
choice: FilePanelChoice;
serverId: string;
workspaceRoot: string;
client: DaemonClient;
@@ -502,10 +482,7 @@ function EditableFilePane({
onRetryRead: () => void;
retryingRead: boolean;
filename: string;
mode?: "preview" | "source";
onModeChange?: (mode: "preview" | "source") => void;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
@@ -599,6 +576,7 @@ function EditableFilePane({
onRetry: onRetryRead,
retrying: retryingRead,
});
const setText = useCallback((text: string) => model.edit(text), [model]);
const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []);
const renderedPreview = useMemo<ExplorerFile>(
() => ({
@@ -610,7 +588,11 @@ function EditableFilePane({
}),
[preview, snapshot.content, snapshot.version],
);
const showSource = mode !== "preview";
const sourceActive = panel !== null && isSourceFilePanel(panel);
const sourceStatus = useMemo(
() => (sourceActive ? <FileSourceStatus cursor={cursor} vimMode={vimMode} /> : null),
[cursor, sourceActive, vimMode],
);
return (
<View style={styles.container} testID="workspace-file-pane">
@@ -620,13 +602,11 @@ function EditableFilePane({
}
lineCount={snapshot.content.split("\n").length}
editorStatus={snapshot.status}
cursor={showSource ? cursor : undefined}
vimMode={showSource ? vimMode : null}
conflict={conflict}
mode={mode}
onModeChange={onModeChange}
choice={choice}
trailing={sourceStatus}
/>
{showSource ? (
{sourceActive ? (
<FileEditorView
model={model}
filename={filename}
@@ -638,14 +618,15 @@ function EditableFilePane({
onVimModeChange={handleVimModeChange}
/>
) : (
<FilePreviewBody
<FilePanelBody
bytes={bytes}
panel={panel}
setText={setText}
preview={renderedPreview}
mode={mode}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={null}
documentPreviewUri={documentPreviewUri}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
@@ -708,15 +689,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
previewScrollContainer: {
flex: 1,
minHeight: 0,
},
previewContent: {
flex: 1,
minHeight: 0,
},
previewCodeScrollContent: {
padding: theme.spacing[4],
},
}));
@@ -1,4 +1,5 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import type { FilePanel } from "@/file-panels";
import type { LiveFileSnapshot } from "../live-file/model";
import {
createFilePanePreview,
@@ -10,14 +11,15 @@ import {
export function useFilePreview(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
panel: FilePanel | null;
}): FilePreviewLifecycleSnapshot {
const [model] = useState(() => new FilePreviewLifecycleModel(createFilePanePreview));
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const { targetKey, liveFileSnapshot } = input;
const { targetKey, liveFileSnapshot, panel } = input;
useEffect(() => {
model.setSource({ targetKey, liveFileSnapshot });
}, [liveFileSnapshot, model, targetKey]);
model.setSource({ targetKey, liveFileSnapshot, panel });
}, [liveFileSnapshot, model, targetKey, panel]);
return snapshot;
}
@@ -2,6 +2,7 @@ import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { LiveFileSnapshot } from "../live-file/model";
import { describe, expect, it } from "vitest";
import {
createFilePanePreview,
FilePreviewLifecycleModel,
type FilePanePreview,
type FilePreviewLifecycleSnapshot,
@@ -75,8 +76,9 @@ function source(
): {
targetKey: string;
liveFileSnapshot: LiveFileSnapshot;
panel: null;
} {
return { targetKey, liveFileSnapshot: snapshot };
return { targetKey, liveFileSnapshot: snapshot, panel: null };
}
describe("FilePreviewLifecycleModel", () => {
@@ -88,7 +90,8 @@ describe("FilePreviewLifecycleModel", () => {
const model = new FilePreviewLifecycleModel(() => preparations.shift()!.promise);
const preview: FilePanePreview = {
file: previewFile("preview"),
imageAttachment: null,
panel: null,
bytesPreview: null,
};
model.setSource(source("/workspace:file.ts", pending()));
@@ -134,12 +137,13 @@ describe("FilePreviewLifecycleModel", () => {
);
const nextPreview: FilePanePreview = {
file: previewFile("two"),
imageAttachment: null,
panel: null,
bytesPreview: null,
};
model.setSource(source("/workspace:file.ts", completed(file("one"))));
model.setSource(source("/workspace:second.ts", completed(file("two", "second.ts"))));
first.resolve({ file: previewFile("one"), imageAttachment: null });
first.resolve({ file: previewFile("one"), panel: null, bytesPreview: null });
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "preparing" });
@@ -155,3 +159,41 @@ async function expectSnapshot(
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(model.getSnapshot()).toEqual(expected);
}
it("does not persist attachments when a text file has no selected panel", async () => {
let writes = 0;
await createFilePanePreview(file(), null, async () => {
writes++;
return {
id: "attachment",
mimeType: "text/plain",
storageType: "web-indexeddb",
storageKey: "attachment",
createdAt: 0,
};
});
expect(writes).toBe(0);
});
it("reprepares the same file when its selected panel changes", async () => {
const preparations: Array<string | null> = [];
const model = new FilePreviewLifecycleModel(async (raw, panel) => {
preparations.push(panel?.id ?? null);
return { file: previewFile("preview"), panel, bytesPreview: null };
});
const initial = source("/workspace:file.ts", completed(file()));
model.setSource(initial);
await Promise.resolve();
const textPanel = {
id: "builtin:markdown",
builtin: true,
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
} as const;
model.setSource({ ...initial, panel: textPanel });
await Promise.resolve();
model.setSource({ ...initial, panel: textPanel });
expect(preparations).toEqual([null, "builtin:markdown"]);
expect(model.getSnapshot()).toMatchObject({ status: "ready", preview: { panel: textPanel } });
});
@@ -3,12 +3,14 @@ import type { AttachmentMetadata } from "@/attachments/types";
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { FilePanel } from "@/file-panels";
import type { ExplorerFile } from "@/stores/session-store";
import type { LiveFileSnapshot } from "../live-file/model";
export interface FilePanePreview {
file: ExplorerFile;
imageAttachment: AttachmentMetadata | null;
panel: FilePanel | null;
bytesPreview: { bytes: Uint8Array; attachment: AttachmentMetadata } | null;
}
export type FilePreviewLifecycleSnapshot =
@@ -22,13 +24,17 @@ export type FilePreviewLifecycleSnapshot =
const initialSnapshot: FilePreviewLifecycleSnapshot = { status: "initial" };
/** Converts a completed raw read into the preview resources consumed by FilePane. */
export async function createFilePanePreview(file: FileReadResult): Promise<FilePanePreview | null> {
export async function createFilePanePreview(
file: FileReadResult,
panel: FilePanel | null,
persist: typeof persistAttachmentFromBytes = persistAttachmentFromBytes,
): Promise<FilePanePreview | null> {
const explorerFile = explorerFileFromReadResult(file);
if (file.kind !== "image") {
return { file: explorerFile, imageAttachment: null };
if (panel?.document !== "bytes") {
return { file: explorerFile, panel, bytesPreview: null };
}
const imageAttachment = await persistAttachmentFromBytes({
const documentAttachment = await persist({
id: createPreviewAttachmentId({
mimeType: file.mime,
path: file.path,
@@ -41,7 +47,11 @@ export async function createFilePanePreview(file: FileReadResult): Promise<FileP
fileName: getFileNameFromPath(file.path),
});
return { file: explorerFile, imageAttachment };
return {
file: explorerFile,
panel,
bytesPreview: { bytes: file.bytes, attachment: documentAttachment },
};
}
/** Owns conversion after LiveFileModel has produced a raw file snapshot. */
@@ -51,9 +61,13 @@ export class FilePreviewLifecycleModel {
private generation = 0;
private preparedSourceKey: string | null = null;
private targetKey: string | null = null;
private preparedPanel: FilePanel | null = null;
constructor(
private readonly prepare: (file: FileReadResult) => Promise<FilePanePreview | null>,
private readonly prepare: (
file: FileReadResult,
panel: FilePanel | null,
) => Promise<FilePanePreview | null>,
) {}
subscribe = (listener: () => void): (() => void) => {
@@ -63,7 +77,11 @@ export class FilePreviewLifecycleModel {
getSnapshot = (): FilePreviewLifecycleSnapshot => this.snapshot;
setSource(input: { targetKey: string | null; liveFileSnapshot: LiveFileSnapshot }): void {
setSource(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
panel: FilePanel | null;
}): void {
const targetChanged = input.targetKey !== this.targetKey;
const retainedPreview = targetChanged ? undefined : filePreviewFromLifecycle(this.snapshot);
if (targetChanged) {
@@ -74,16 +92,22 @@ export class FilePreviewLifecycleModel {
const source = getReadySource(input);
if (source) {
if (source.key === this.preparedSourceKey) {
if (source.key === this.preparedSourceKey && input.panel === this.preparedPanel) {
return;
}
this.preparedSourceKey = source.key;
this.preparedPanel = input.panel;
const generation = ++this.generation;
this.setSnapshot({
status: "preparing",
...(retainedPreview ? { preview: retainedPreview } : {}),
});
void this.prepareSource({ file: source.file, generation, retainedPreview });
void this.prepareSource({
file: source.file,
panel: input.panel,
generation,
retainedPreview,
});
return;
}
@@ -109,11 +133,12 @@ export class FilePreviewLifecycleModel {
private async prepareSource(input: {
file: FileReadResult;
panel: FilePanel | null;
generation: number;
retainedPreview?: FilePanePreview;
}): Promise<void> {
try {
const preview = await this.prepare(input.file);
const preview = await this.prepare(input.file, input.panel);
if (input.generation !== this.generation) return;
this.setSnapshot(preview ? { status: "ready", preview } : { status: "unsupported" });
} catch (error) {
@@ -135,10 +160,18 @@ export function filePreviewFromLifecycle(
export function resolveFilePreviewLifecycle(snapshot: FilePreviewLifecycleSnapshot): {
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
panel: FilePanel | null;
bytes: Uint8Array | null;
documentAttachment: AttachmentMetadata | null;
} {
const preview = filePreviewFromLifecycle(snapshot);
return preview ?? { file: null, imageAttachment: null };
if (!preview) return { file: null, panel: null, bytes: null, documentAttachment: null };
return {
file: preview.file,
panel: preview.panel,
bytes: preview.bytesPreview ? preview.bytesPreview.bytes : null,
documentAttachment: preview.bytesPreview ? preview.bytesPreview.attachment : null,
};
}
function getReadySource(input: {
@@ -0,0 +1,34 @@
import { useCallback } from "react";
import { useAppSettings } from "@/hooks/use-settings";
export interface FilePanelAssociations {
/** Selector key (`ext:md`, `name:Dockerfile`) → panel id, device-wide. */
associations: Readonly<Record<string, string>>;
/** Writes the association for a selector key, or forgets it with null. */
remember(selectorKey: string, panelId: string | null): void;
}
/** The one reader and writer of the device's file panel associations. */
export function useFilePanelAssociations(): FilePanelAssociations {
const { settings, updateSettings } = useAppSettings();
const remember = useCallback(
(selectorKey: string, panelId: string | null) => {
void updateSettings((current) => ({
filePanelAssociations: withAssociation(current.filePanelAssociations, selectorKey, panelId),
}));
},
[updateSettings],
);
return { associations: settings.filePanelAssociations, remember };
}
function withAssociation(
associations: Readonly<Record<string, string>>,
selectorKey: string,
panelId: string | null,
): Record<string, string> {
const next = { ...associations };
if (panelId === null) delete next[selectorKey];
else next[selectorKey] = panelId;
return next;
}
+92
View File
@@ -0,0 +1,92 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ScrollView, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { Theme } from "@/styles/theme";
import { FileHtmlPreview } from "./html-preview";
import { FileMarkdownPreview } from "./markdown-preview";
import type { RenderingFilePanel } from "./catalog";
interface BuiltinFilePanelProps {
content: string;
filePath: string;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
documentPreviewUri: string | null;
}
function MarkdownPanel(props: BuiltinFilePanelProps) {
return (
<View style={styles.previewScrollContainer}>
<ScrollView style={styles.previewContent} showsVerticalScrollIndicator>
<FileMarkdownPreview
source={props.content}
filePath={props.filePath}
workspaceRoot={props.workspaceRoot}
client={props.client}
serverId={props.serverId}
/>
</ScrollView>
</View>
);
}
function HtmlPanel({ content }: BuiltinFilePanelProps) {
return (
<View style={styles.previewScrollContainer}>
<FileHtmlPreview html={content} testID="file-html-preview" />
</View>
);
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ImagePanel({ documentPreviewUri }: BuiltinFilePanelProps) {
const { t } = useTranslation();
if (!documentPreviewUri) {
return (
<View style={styles.centerState}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
return <ZoomableImage uri={documentPreviewUri} testID="image-file-preview" />;
}
const components = {
"builtin:markdown": MarkdownPanel,
"builtin:html": HtmlPanel,
"builtin:image": ImagePanel,
};
export function BuiltinFilePanelView({
panel,
...props
}: BuiltinFilePanelProps & {
panel: RenderingFilePanel;
}) {
const Component = components[panel.id];
return <Component {...props} />;
}
const styles = StyleSheet.create((theme) => ({
previewScrollContainer: { flex: 1, minHeight: 0 },
previewContent: { flex: 1, minHeight: 0 },
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
padding: theme.spacing[4],
},
loadingText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { filePanelCatalog } from "./catalog";
import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
const builtins = filePanelCatalog([]);
const richMarkdown: FilePanelCandidate = {
id: "plugin:notes/rich",
builtin: false,
files: { extensions: ["md"] },
document: "text",
maxBytes: Infinity,
};
function textFile(path: string): FilePanelFile {
return { path, kind: "text", size: 100, hasBytes: true };
}
function candidateIds(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) {
return resolveFilePanel({ file, candidates, associations: {} }).candidates.map(
(candidate) => candidate.id,
);
}
function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) {
return resolveFilePanel({ file, candidates, associations: {} }).panel?.id ?? null;
}
describe("built-in file panels", () => {
it("opens each rendered extension with its panel and Source after it", () => {
expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown", "builtin:source"]);
expect(panelId(textFile("notes.markdown"))).toBe("builtin:markdown");
expect(panelId(textFile("plan.html"))).toBe("builtin:html");
expect(panelId(textFile("docs/PLAN.HTML"))).toBe("builtin:html");
expect(panelId(textFile("plan.htm"))).toBe("builtin:html");
});
it("opens every other text file with Source, whatever its name", () => {
for (const path of ["src/index.ts", "page.mdx", "index.html.erb", "Dockerfile", "LICENSE"]) {
expect(candidateIds(textFile(path))).toEqual(["builtin:source"]);
}
});
it("puts plugin panels after the rendering built-ins and before Source", () => {
expect(candidateIds(textFile("README.md"), filePanelCatalog([richMarkdown]))).toEqual([
"builtin:markdown",
richMarkdown.id,
"builtin:source",
]);
const onlyPlugin = filePanelCatalog([{ ...richMarkdown, files: { extensions: ["txt"] } }]);
expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id);
});
it("has no Source for files that are not text", () => {
expect(
candidateIds({ path: "archive.zip", kind: "binary", size: 512, hasBytes: true }),
).toEqual([]);
});
it.each(["png", "jpg", "jpeg", "gif", "webp", "svg"])(
"registers the image extension %s",
(extension) => {
const resolution = resolveFilePanel({
file: {
path: `/capture.${extension.toUpperCase()}`,
kind: "image",
size: 512,
hasBytes: true,
},
candidates: builtins,
associations: { [`ext:${extension}`]: "builtin:markdown" },
});
expect(resolution.panel?.id).toBe("builtin:image");
expect(
resolveFilePanel({
file: { path: "capture.png", kind: "binary", size: 512, hasBytes: false },
candidates: builtins,
associations: {},
}).panel,
).toBeNull();
},
);
it("does not impose a size limit on built-in text panels", () => {
expect(candidateIds({ ...textFile("notes.md"), size: 11 * 1024 * 1024 })).toEqual([
"builtin:markdown",
"builtin:source",
]);
});
});
+54
View File
@@ -0,0 +1,54 @@
import type { FilePanelCandidate } from "./resolve";
const renderingPanels = [
{
id: "builtin:markdown",
builtin: true,
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
},
{
id: "builtin:html",
builtin: true,
files: { extensions: ["html", "htm"] },
document: "text",
maxBytes: Infinity,
},
{
id: "builtin:image",
builtin: true,
files: { extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] },
document: "bytes",
maxBytes: Infinity,
},
] as const satisfies readonly FilePanelCandidate[];
/** Paseo's editor. Every text file has it, so a text file always opens with some panel. */
export const sourceFilePanel = {
id: "builtin:source",
builtin: true,
files: "all",
document: "text",
maxBytes: Infinity,
} as const satisfies FilePanelCandidate;
export type RenderingFilePanel = (typeof renderingPanels)[number];
export type SourceFilePanel = typeof sourceFilePanel;
/**
* Every panel a host offers, in resolver order: the built-ins that render a file, then plugin
* panels, then Source. Source is last so it is the default only when nothing else claims the file.
*/
export function filePanelCatalog<Plugin extends FilePanelCandidate>(
pluginPanels: readonly Plugin[],
): readonly (RenderingFilePanel | Plugin | SourceFilePanel)[] {
return [...renderingPanels, ...pluginPanels, sourceFilePanel];
}
/** Source is Paseo's own editor, so the file tab renders it rather than the panel host. */
export function isSourceFilePanel<Panel extends { id: string }>(
panel: Panel | SourceFilePanel,
): panel is SourceFilePanel {
return panel.id === sourceFilePanel.id;
}
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
import { useFilePanelAssociations } from "../associations";
import { sourceFilePanel } from "../catalog";
import { useFilePanels, type FilePanel } from "../panels";
import { resolveFileReadPanels } from "../resolve";
import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model";
import { getFilePanelChoice, registerFilePanelChoice, subscribeFilePanelChoices } from "./registry";
export type FilePanelChoice = FilePanelChoiceModel<FilePanel>;
/**
* The file tab's panel: resolves it from the catalog, the device associations and this tab's
* choice, and owns the picker and "Open with…" for it.
*/
export function useFilePanelChoice(input: {
serverId: string;
targetKey: string | null;
path: string;
file: FileReadResult | null;
/** The tab is on screen. */
presentable: boolean;
/** Identifies the tab's latest request to show a line, or null when it targets no line. */
lineNavigation: number | null;
}): { panel: FilePanel | null; choice: FilePanelChoice } {
const { targetKey, path, file, presentable, lineNavigation } = input;
const { serverId, workspaceId, tabId } = usePaneContext();
const candidates = useFilePanels(input.serverId);
const { associations, remember } = useFilePanelAssociations();
const [choice] = useState(
() => new FilePanelChoiceModel<FilePanel>({ remember, linePanelId: sourceFilePanel.id }),
);
// From the subscribed snapshot: React Compiler caches reads off the stable model object.
const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey);
const resolution = useMemo(
() => resolveFileReadPanels({ file, path, candidates, associations, choice: chosenPanelId }),
[associations, candidates, chosenPanelId, file, path],
);
useEffect(() => {
choice.observe({ targetKey, resolution, presentable, lineNavigation });
}, [choice, lineNavigation, presentable, resolution, targetKey]);
useEffect(
() => registerFilePanelChoice({ serverId, workspaceId, tabId }, choice),
[choice, serverId, tabId, workspaceId],
);
return { panel: resolution?.panel ?? null, choice };
}
const NO_CHOICE_SNAPSHOT: FilePanelChoiceSnapshot<FilePanel> = {
targetKey: null,
chosenPanelId: null,
picker: null,
openWith: null,
};
const subscribeNothing = () => () => {};
const getNoChoiceSnapshot = () => NO_CHOICE_SNAPSHOT;
/** The choice of an open file tab, looked up by the tab's identity, for chrome outside its pane. */
export function useTabFilePanelChoice(identity: PanelInstanceIdentity): FilePanelChoice | null {
const { serverId, workspaceId, tabId } = identity;
const getSnapshot = useCallback(
() => getFilePanelChoice({ serverId, workspaceId, tabId }),
[serverId, tabId, workspaceId],
);
return useSyncExternalStore(subscribeFilePanelChoices, getSnapshot, getSnapshot);
}
export function useFilePanelChoiceSnapshot(
choice: FilePanelChoice | null,
): FilePanelChoiceSnapshot<FilePanel> {
return useSyncExternalStore(
choice?.subscribe ?? subscribeNothing,
choice?.getSnapshot ?? getNoChoiceSnapshot,
choice?.getSnapshot ?? getNoChoiceSnapshot,
);
}
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import { filePanelCatalog, sourceFilePanel } from "../catalog";
import { resolveFilePanel, type FilePanelCandidate } from "../resolve";
import { chosenPanelFor, FilePanelChoiceModel } from "./model";
const rich: FilePanelCandidate = {
id: "plugin:notes/rich",
builtin: false,
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
};
const markdown = "builtin:markdown";
const source = sourceFilePanel.id;
/** A file tab: resolves against the shared associations and feeds the result to its model. */
function openTab(
associations: Record<string, string>,
candidates: FilePanelCandidate[] = [...filePanelCatalog([rich])],
options: { presentable?: boolean; path?: string; lineNavigation?: number | null } = {},
) {
const model = new FilePanelChoiceModel<FilePanelCandidate>({
remember(selectorKey, panelId) {
if (panelId === null) delete associations[selectorKey];
else associations[selectorKey] = panelId;
},
linePanelId: source,
});
let targetKey = `/repo:${options.path ?? "notes.md"}`;
let presentable = options.presentable ?? true;
let lineNavigation = options.lineNavigation ?? null;
const resolve = () =>
resolveFilePanel({
file: { path: targetKey.split(":")[1]!, kind: "text", size: 10, hasBytes: true },
candidates,
associations,
choice: chosenPanelFor(model.getSnapshot(), targetKey),
});
// Like the hook: observe, then render what the model's choice resolves to.
const render = () => {
model.observe({ targetKey, resolution: resolve(), presentable, lineNavigation });
return resolve().panel?.id ?? null;
};
render();
return {
model,
render,
panel: () => render(),
picker: () => model.getSnapshot().picker,
retarget(path: string) {
targetKey = `/repo:${path}`;
return render();
},
setPresentable(next: boolean) {
presentable = next;
return render();
},
navigateToLine(revision: number) {
lineNavigation = revision;
return render();
},
};
}
describe("FilePanelChoiceModel", () => {
it("asks once per tab open over the first candidate, and dismissing keeps it", () => {
const associations = {};
const tab = openTab(associations);
expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toMatchObject({
selectorKey: "ext:md",
selectedPanelId: markdown,
remember: false,
});
expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([
markdown,
rich.id,
source,
]);
tab.model.dismissPicker();
expect(tab.picker()).toBeNull();
expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toBeNull();
expect(associations).toEqual({});
expect(openTab(associations).picker()).not.toBeNull();
});
it("never asks between built-in panels, and switches between them", () => {
const tab = openTab({}, [...filePanelCatalog([])]);
expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toBeNull();
expect(tab.model.getSnapshot().openWith?.candidates.map((candidate) => candidate.id)).toEqual([
markdown,
source,
]);
tab.model.openWith(source);
expect(tab.panel()).toBe(source);
expect(tab.picker()).toBeNull();
});
it("opens a file only Source claims with Source and nothing to choose", () => {
const tab = openTab({}, [...filePanelCatalog([rich])], { path: "index.ts" });
expect(tab.panel()).toBe(source);
expect(tab.picker()).toBeNull();
expect(tab.model.getSnapshot().openWith).toBeNull();
});
it("remembers the choice when Don't ask me again is checked", () => {
const associations: Record<string, string> = {};
const tab = openTab(associations);
tab.model.selectInPicker(rich.id);
tab.model.setRemember(true);
tab.model.confirmPicker();
expect(tab.panel()).toBe(rich.id);
expect(associations).toEqual({ "ext:md": rich.id });
const next = openTab(associations);
expect(next.panel()).toBe(rich.id);
expect(next.picker()).toBeNull();
});
it("uses an unremembered choice for this tab only and asks again on the next open", () => {
const associations = {};
const tab = openTab(associations);
tab.model.selectInPicker(rich.id);
tab.model.confirmPicker();
expect(tab.panel()).toBe(rich.id);
expect(associations).toEqual({});
const next = openTab(associations);
expect(next.panel()).toBe(markdown);
expect(next.picker()).not.toBeNull();
});
it("does not ask with an association or while the tab is in the background", () => {
expect(openTab({ "ext:md": rich.id }).picker()).toBeNull();
expect(openTab({ "ext:md": source }).panel()).toBe(source);
const background = openTab({}, undefined, { presentable: false });
expect(background.picker()).toBeNull();
background.setPresentable(true);
expect(background.picker()).not.toBeNull();
});
it("opens Source for a line target without asking, and again on each new one", () => {
const tab = openTab({}, undefined, { lineNavigation: 1 });
expect(tab.panel()).toBe(source);
expect(tab.picker()).toBeNull();
tab.model.openWith(rich.id);
expect(tab.panel()).toBe(rich.id);
expect(tab.navigateToLine(1)).toBe(rich.id);
expect(tab.navigateToLine(2)).toBe(source);
});
it("opens Source for the same line target in a retargeted tab", () => {
const tab = openTab({}, undefined, { lineNavigation: 1 });
tab.model.openWith(markdown);
expect(tab.retarget("other.md")).toBe(source);
});
it("starts a new open when the tab is retargeted", () => {
const tab = openTab({});
tab.model.selectInPicker(rich.id);
tab.model.confirmPicker();
expect(tab.retarget("other.md")).toBe(markdown);
expect(tab.picker()).not.toBeNull();
});
it("switches with Open with and toggles Always use without moving the tab", () => {
const associations: Record<string, string> = {};
const tab = openTab(associations);
tab.model.dismissPicker();
expect(tab.model.getSnapshot().openWith).toMatchObject({
activePanelId: markdown,
remembered: false,
});
tab.model.openWith(rich.id);
expect(tab.panel()).toBe(rich.id);
tab.model.toggleAlwaysUse();
expect(associations).toEqual({ "ext:md": rich.id });
tab.render();
expect(tab.model.getSnapshot().openWith?.remembered).toBe(true);
tab.model.openWith(markdown);
expect(tab.panel()).toBe(markdown);
expect(tab.model.getSnapshot().openWith?.remembered).toBe(false);
tab.model.openWith(rich.id);
tab.render();
tab.model.toggleAlwaysUse();
expect(associations).toEqual({});
expect(tab.panel()).toBe(rich.id);
});
it("asks when a plugin panel arrives after the file first rendered", () => {
const candidates: FilePanelCandidate[] = [...filePanelCatalog([])];
const tab = openTab({}, candidates);
expect(tab.picker()).toBeNull();
candidates.splice(1, 0, rich);
tab.render();
expect(tab.picker()).not.toBeNull();
});
});
@@ -0,0 +1,195 @@
import { asksForFilePanel, type FilePanelCandidate, type FilePanelResolution } from "../resolve";
/** What the file tab currently resolves; `resolution` is null until the file has been read. */
export interface FilePanelChoiceSource<Candidate extends FilePanelCandidate> {
targetKey: string | null;
resolution: FilePanelResolution<Candidate> | null;
/** The tab is on screen, so a picker shown now is seen over the panel it chooses for. */
presentable: boolean;
/** Identifies the tab's latest request to show a line, or null when it targets no line. */
lineNavigation: number | null;
}
export interface FilePanelPickerState<Candidate> {
selectorKey: string;
candidates: readonly Candidate[];
selectedPanelId: string;
remember: boolean;
}
export interface FilePanelOpenWithState<Candidate> {
selectorKey: string;
candidates: readonly Candidate[];
activePanelId: string;
/** The active panel is the one remembered for `selectorKey`. */
remembered: boolean;
}
export interface FilePanelChoiceSnapshot<Candidate> {
targetKey: string | null;
/** The panel picked for this tab open. Resolution prefers it over the association. */
chosenPanelId: string | null;
picker: FilePanelPickerState<Candidate> | null;
/** Present only when the file has more than one candidate; drives the bar's panel switcher. */
openWith: FilePanelOpenWithState<Candidate> | null;
}
export interface FilePanelChoiceDeps {
/** Writes (or clears, with null) the device-wide association for a selector key. */
remember(selectorKey: string, panelId: string | null): void;
/** The panel a line target opens: Source, the panel that shows lines. */
linePanelId: string;
}
/** The tab choice that applies to `targetKey`; a choice never outlives its open. */
export function chosenPanelFor(
snapshot: FilePanelChoiceSnapshot<unknown>,
targetKey: string | null,
): string | null {
return snapshot.targetKey === targetKey ? snapshot.chosenPanelId : null;
}
const EMPTY: FilePanelChoiceSnapshot<never> = {
targetKey: null,
chosenPanelId: null,
picker: null,
openWith: null,
};
/**
* Which file panel one file tab shows, and whether to ask.
*
* A tab open is one target in one tab: the picker is offered at most once per open, the first
* time the tab is on screen with a plugin panel among several candidates and nothing remembered.
* Built-in panels alone never ask. A line target opens the line panel the way "Open with…" does.
* Retargeting the tab starts a new open. Choices made here apply to this tab; only "Don't ask me
* again" and "Always use" write the association every later open reads.
*/
export class FilePanelChoiceModel<Candidate extends FilePanelCandidate> {
private readonly listeners = new Set<() => void>();
private snapshot: FilePanelChoiceSnapshot<Candidate> = EMPTY;
private asked = false;
private openedLineNavigation: number | null = null;
constructor(private readonly deps: FilePanelChoiceDeps) {}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FilePanelChoiceSnapshot<Candidate> => this.snapshot;
observe(source: FilePanelChoiceSource<Candidate>): void {
let current = this.snapshot;
if (source.targetKey !== current.targetKey) {
this.asked = false;
this.openedLineNavigation = null;
current = { ...EMPTY, targetKey: source.targetKey };
}
if (source.lineNavigation !== null && source.lineNavigation !== this.openedLineNavigation) {
this.openedLineNavigation = source.lineNavigation;
current = { ...current, chosenPanelId: this.deps.linePanelId, picker: null };
}
const openWith = stableOpenWith(current.openWith, openWithState(source.resolution));
let picker = openWith ? current.picker : null;
// Decided once per open, the first time there is something to ask about on screen. An
// association forgotten while the tab stays open is asked about on the next open.
if (!this.asked && source.presentable && openWith && asksForFilePanel(openWith.candidates)) {
this.asked = true;
const remembered = Boolean(source.resolution?.associatedPanelId);
if (!remembered && current.chosenPanelId === null) {
picker = {
selectorKey: openWith.selectorKey,
candidates: openWith.candidates,
selectedPanelId: openWith.activePanelId,
remember: false,
};
}
}
this.publish({ ...current, openWith, picker });
}
selectInPicker(panelId: string): void {
const picker = this.snapshot.picker;
if (!picker) return;
this.publish({ ...this.snapshot, picker: { ...picker, selectedPanelId: panelId } });
}
setRemember(remember: boolean): void {
const picker = this.snapshot.picker;
if (!picker) return;
this.publish({ ...this.snapshot, picker: { ...picker, remember } });
}
confirmPicker(): void {
const picker = this.snapshot.picker;
if (!picker) return;
if (picker.remember) this.deps.remember(picker.selectorKey, picker.selectedPanelId);
this.publish({ ...this.snapshot, chosenPanelId: picker.selectedPanelId, picker: null });
}
/** Keeps whatever the tab shows now; the next open asks again. */
dismissPicker(): void {
if (!this.snapshot.picker) return;
this.publish({ ...this.snapshot, picker: null });
}
openWith(panelId: string): void {
this.publish({ ...this.snapshot, chosenPanelId: panelId, picker: null });
}
/** Remembers the active panel, or forgets it when it already is the remembered one. */
toggleAlwaysUse(): void {
const openWith = this.snapshot.openWith;
if (!openWith) return;
if (!openWith.remembered) {
this.deps.remember(openWith.selectorKey, openWith.activePanelId);
return;
}
// Forgetting must not move the tab off the panel it is showing.
this.publish({ ...this.snapshot, chosenPanelId: openWith.activePanelId });
this.deps.remember(openWith.selectorKey, null);
}
private publish(next: FilePanelChoiceSnapshot<Candidate>): void {
const current = this.snapshot;
if (
current.targetKey === next.targetKey &&
current.chosenPanelId === next.chosenPanelId &&
current.picker === next.picker &&
current.openWith === next.openWith
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) listener();
}
}
function openWithState<Candidate extends FilePanelCandidate>(
resolution: FilePanelResolution<Candidate> | null,
): FilePanelOpenWithState<Candidate> | null {
if (!resolution?.panel || resolution.candidates.length < 2) return null;
return {
selectorKey: resolution.selectorKey,
candidates: resolution.candidates,
activePanelId: resolution.panel.id,
remembered: resolution.associatedPanelId === resolution.panel.id,
};
}
/** Keeps the published object when nothing in it changed, so subscribers do not re-render. */
function stableOpenWith<Candidate>(
current: FilePanelOpenWithState<Candidate> | null,
next: FilePanelOpenWithState<Candidate> | null,
): FilePanelOpenWithState<Candidate> | null {
if (!current || !next) return next;
const same =
current.selectorKey === next.selectorKey &&
current.activePanelId === next.activePanelId &&
current.remembered === next.remembered &&
current.candidates.length === next.candidates.length &&
current.candidates.every((candidate, index) => candidate === next.candidates[index]);
return same ? current : next;
}
@@ -0,0 +1,175 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { Ellipsis } from "lucide-react-native";
import {
MenuItem,
MenuSeparator,
MenuSubTrigger,
type MenuPageDefinition,
} from "@/components/ui/menu";
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
import { PluginIconView } from "@/plugins/icons";
import type { Theme } from "@/styles/theme";
import type { FilePanel } from "../panels";
import { useFilePanelChoiceSnapshot, useTabFilePanelChoice, type FilePanelChoice } from "./hook";
import { filePanelAlwaysUseLabel, filePanelIcon, filePanelOptionLabel } from "./presentation";
const OPEN_WITH_PAGE_ID = "filePanelOpenWith";
const MENU_ICON_SIZE = 16;
const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedPanelIcon = withUnistyles(PluginIconView);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const NO_PAGES: readonly MenuPageDefinition[] = [];
export interface FilePanelOpenWithMenu {
/** The "Open with…" row, or null when the file has one panel or none. */
trigger: ReactElement | null;
/** The page the row opens; pass to the menu surface. */
pages: readonly MenuPageDefinition[];
}
/** "Open with…" for a file tab's context menu, found by the tab's identity. */
export function useFilePanelOpenWithMenu(
identity: PanelInstanceIdentity,
testIDBase: string,
): FilePanelOpenWithMenu {
return useOpenWithMenu(useTabFilePanelChoice(identity), testIDBase);
}
/**
* The file bar's overflow: remembering the panel the bar's switcher shows. Renders nothing unless
* the file has more than one panel.
*/
export function FilePanelBarMenu({ choice }: { choice: FilePanelChoice }) {
const { t } = useTranslation();
const compact = useIsCompactFormFactor();
const { openWith } = useFilePanelChoiceSnapshot(choice);
if (!openWith) return null;
const label = t("panels.file.filePanels.actions");
return (
<DropdownMenu compactMode="sheet">
<ToolbarButton kind="menu" label={label} compact={compact} testID="file-panel-bar-menu">
<ThemedEllipsis
size={paneContentToolbarIconSize(compact)}
uniProps={extraMutedIconColorMapping}
/>
</ToolbarButton>
<DropdownMenuContent
align="end"
width={240}
sheetTitle={label}
testID="file-panel-bar-menu-surface"
>
<AlwaysUseItem
choice={choice}
label={filePanelAlwaysUseLabel(t, openWith.selectorKey)}
selected={openWith.remembered}
testID="file-panel-bar-menu-always-use"
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
function useOpenWithMenu(
choice: FilePanelChoice | null,
testIDBase: string,
): FilePanelOpenWithMenu {
const { t } = useTranslation();
const { openWith } = useFilePanelChoiceSnapshot(choice);
return useMemo(() => {
if (!choice || !openWith) return { trigger: null, pages: NO_PAGES };
const title = t("panels.file.filePanels.openWith");
return {
trigger: (
<MenuSubTrigger id={OPEN_WITH_PAGE_ID} testID={`${testIDBase}-open-with`}>
{title}
</MenuSubTrigger>
),
pages: [
{
id: OPEN_WITH_PAGE_ID,
title,
content: (
<>
{openWith.candidates.map((panel) => (
<OpenWithItem
key={panel.id}
choice={choice}
panel={panel}
label={filePanelOptionLabel(t, panel)}
selected={panel.id === openWith.activePanelId}
testID={`${testIDBase}-open-with-${panel.id}`}
/>
))}
<MenuSeparator />
<AlwaysUseItem
choice={choice}
label={filePanelAlwaysUseLabel(t, openWith.selectorKey)}
selected={openWith.remembered}
testID={`${testIDBase}-always-use`}
/>
</>
),
},
],
};
}, [choice, openWith, t, testIDBase]);
}
function OpenWithItem({
choice,
panel,
label,
selected,
testID,
}: {
choice: FilePanelChoice;
panel: FilePanel;
label: string;
selected: boolean;
testID: string;
}) {
const handleSelect = useCallback(() => choice.openWith(panel.id), [choice, panel.id]);
const leading = useMemo(
() => (
<ThemedPanelIcon
icon={filePanelIcon(panel)}
size={MENU_ICON_SIZE}
uniProps={mutedIconMapping}
/>
),
[panel],
);
return (
<MenuItem selected={selected} leading={leading} onSelect={handleSelect} testID={testID}>
{label}
</MenuItem>
);
}
function AlwaysUseItem({
choice,
label,
selected,
testID,
}: {
choice: FilePanelChoice;
label: string;
selected: boolean;
testID: string;
}) {
const handleSelect = useCallback(() => choice.toggleAlwaysUse(), [choice]);
return (
<MenuItem selected={selected} onSelect={handleSelect} testID={testID}>
{label}
</MenuItem>
);
}
@@ -0,0 +1,233 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PressHighlight } from "@/components/ui/press-highlight";
import { PluginIconView } from "@/plugins/icons";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import type { FilePanel } from "../panels";
import type { FilePanelPickerState } from "./model";
import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook";
import { filePanelIcon, filePanelOptionLabel, filePanelPickerTitle } from "./presentation";
const ThemedCheck = withUnistyles(Check);
const ThemedPanelIcon = withUnistyles(PluginIconView);
const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** The first-open question for a file with several panels, over the panel already showing. */
export function FilePanelPicker({ choice }: { choice: FilePanelChoice }) {
const { t } = useTranslation();
const { picker } = useFilePanelChoiceSnapshot(choice);
// The sheet animates out after the model has closed the picker; keep drawing what it asked.
const [shown, setShown] = useState(picker);
if (picker && picker !== shown) setShown(picker);
const content = picker ?? shown;
const dismiss = useCallback(() => choice.dismissPicker(), [choice]);
const confirm = useCallback(() => choice.confirmPicker(), [choice]);
const toggleRemember = useCallback(
() => choice.setRemember(!choice.getSnapshot().picker?.remember),
[choice],
);
const header = useMemo<SheetHeader>(
() => ({ title: content ? filePanelPickerTitle(t, content.selectorKey) : "" }),
[content, t],
);
const footer = useMemo(
() => (
<Button
style={styles.footerButton}
variant="default"
onPress={confirm}
testID="file-panel-picker-use"
>
{t("panels.file.filePanels.use")}
</Button>
),
[confirm, t],
);
return (
<AdaptiveModalSheet
header={header}
visible={picker !== null}
onClose={dismiss}
footer={footer}
snapPoints={PICKER_SNAP_POINTS}
desktopMaxWidth={420}
testID="file-panel-picker"
>
{content ? (
<PickerBody choice={choice} picker={content} onToggleRemember={toggleRemember} />
) : null}
</AdaptiveModalSheet>
);
}
const PICKER_SNAP_POINTS = ["60%", "90%"];
function PickerBody({
choice,
picker,
onToggleRemember,
}: {
choice: FilePanelChoice;
picker: FilePanelPickerState<FilePanel>;
onToggleRemember: () => void;
}) {
const { t } = useTranslation();
const select = useCallback((panelId: string) => choice.selectInPicker(panelId), [choice]);
return (
<>
<View style={settingsStyles.card} accessibilityRole="radiogroup">
{picker.candidates.map((panel, index) => (
<PanelRadioRow
key={panel.id}
panel={panel}
label={filePanelOptionLabel(t, panel)}
selected={panel.id === picker.selectedPanelId}
withBorder={index > 0}
onSelect={select}
/>
))}
</View>
<CheckboxRow
label={t("panels.file.filePanels.dontAskAgain")}
checked={picker.remember}
onToggle={onToggleRemember}
/>
</>
);
}
function PanelRadioRow({
panel,
label,
selected,
withBorder,
onSelect,
}: {
panel: FilePanel;
label: string;
selected: boolean;
withBorder: boolean;
onSelect: (panelId: string) => void;
}) {
const handlePress = useCallback(() => onSelect(panel.id), [onSelect, panel.id]);
const accessibilityState = useMemo(() => ({ checked: selected }), [selected]);
const rowStyle = useMemo(
() => [settingsStyles.row, withBorder ? settingsStyles.rowBorder : null, styles.optionRow],
[withBorder],
);
return (
<PressHighlight
style={rowStyle}
onPress={handlePress}
accessibilityRole="radio"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-checked={selected}
>
<View style={selected ? styles.radioSelected : styles.radio}>
{selected ? <View style={styles.radioDot} /> : null}
</View>
<ThemedPanelIcon icon={filePanelIcon(panel)} size={16} uniProps={mutedIconMapping} />
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{label}
</Text>
</PressHighlight>
);
}
function CheckboxRow({
label,
checked,
onToggle,
}: {
label: string;
checked: boolean;
onToggle: () => void;
}) {
const accessibilityState = useMemo(() => ({ checked }), [checked]);
return (
<PressHighlight
style={styles.checkboxRow}
onPress={onToggle}
accessibilityRole="checkbox"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-checked={checked}
testID="file-panel-picker-remember"
>
<View style={checked ? styles.checkboxChecked : styles.checkbox}>
{checked ? <ThemedCheck size={14} uniProps={checkedIconMapping} /> : null}
</View>
<Text style={settingsStyles.rowTitle}>{label}</Text>
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
footerButton: {
flex: 1,
},
optionRow: {
justifyContent: "flex-start",
gap: theme.spacing[3],
},
radio: {
width: 18,
height: 18,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
radioSelected: {
width: 18,
height: 18,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
radioDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
},
checkboxRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
},
checkbox: {
width: 18,
height: 18,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
width: 18,
height: 18,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.accent,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
}));
@@ -0,0 +1,52 @@
import type { TFunction } from "i18next";
import type { PluginIcon } from "@getpaseo/plugin/client";
import type { FilePanel } from "../panels";
import { parseFilePanelSelectorKey } from "../resolve";
const BUILTIN_PRESENTATION = {
"builtin:markdown": { title: "markdown", icon: "FileText" },
"builtin:html": { title: "html", icon: "FileCode" },
"builtin:image": { title: "image", icon: "Image" },
"builtin:source": { title: "source", icon: "Code" },
} as const;
export function filePanelIcon(panel: FilePanel): PluginIcon {
return panel.builtin ? BUILTIN_PRESENTATION[panel.id].icon : panel.contribution.icon;
}
/** "Markdown", "Rich Markdown": the panel switcher's label. */
export function filePanelTitle(t: TFunction, panel: FilePanel): string {
return panel.builtin
? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`)
: panel.contribution.title;
}
/** "Markdown — Paseo", "Rich Markdown — markdown-panel". */
export function filePanelOptionLabel(t: TFunction, panel: FilePanel): string {
return t("panels.file.filePanels.option", {
title: filePanelTitle(t, panel),
owner: panel.builtin ? t("panels.file.filePanels.builtin.owner") : panel.plugin.id,
});
}
/** ".md" for an extension key, "Dockerfile" for a filename key. */
export function filePanelSelectorLabel(selectorKey: string): string {
const selector = parseFilePanelSelectorKey(selectorKey);
return selector.kind === "name" ? selector.value : `.${selector.value}`;
}
/** "Open .md files with" for an extension key, "Open Dockerfile with" for a filename key. */
export function filePanelPickerTitle(t: TFunction, selectorKey: string): string {
const selector = parseFilePanelSelectorKey(selectorKey);
return selector.kind === "name"
? t("panels.file.filePanels.pickerTitleFilename", { filename: selector.value })
: t("panels.file.filePanels.pickerTitleExtension", { extension: selector.value });
}
/** "Always use for .md", "Always use for Dockerfile". */
export function filePanelAlwaysUseLabel(t: TFunction, selectorKey: string): string {
const selector = parseFilePanelSelectorKey(selectorKey);
return selector.kind === "name"
? t("panels.file.filePanels.alwaysUseFilename", { filename: selector.value })
: t("panels.file.filePanels.alwaysUseExtension", { extension: selector.value });
}
@@ -0,0 +1,42 @@
import {
buildPanelInstanceKey,
type PanelInstanceIdentity,
} from "@/panels/panel-instance-attributes";
import type { FilePanel } from "../panels";
import type { FilePanelChoiceModel } from "./model";
/**
* The open file tabs' choice models, so tab chrome outside the pane (the tab's context menu)
* reads and drives the same choice the pane renders.
*/
const models = new Map<string, FilePanelChoiceModel<FilePanel>>();
const listeners = new Set<() => void>();
export function registerFilePanelChoice(
identity: PanelInstanceIdentity,
model: FilePanelChoiceModel<FilePanel>,
): () => void {
const key = buildPanelInstanceKey(identity);
models.set(key, model);
notify();
return () => {
if (models.get(key) !== model) return;
models.delete(key);
notify();
};
}
export function getFilePanelChoice(
identity: PanelInstanceIdentity,
): FilePanelChoiceModel<FilePanel> | null {
return models.get(buildPanelInstanceKey(identity)) ?? null;
}
export function subscribeFilePanelChoices(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function notify(): void {
for (const listener of listeners) listener();
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import type { FilePanelOpenWithState } from "./model";
import { filePanelSwitcherState } from "./switcher-layout";
function openWith(count: number): FilePanelOpenWithState<{ id: string }> {
const candidates = Array.from({ length: count }, (_, index) => ({ id: `panel-${index}` }));
return { selectorKey: "ext:md", candidates, activePanelId: "panel-1", remembered: false };
}
describe("filePanelSwitcherState", () => {
it("shows nothing when the file has one panel or none", () => {
expect(filePanelSwitcherState(null, { compact: false })).toBeNull();
});
it("sets up to three panels side by side and more behind a menu", () => {
const wide = { compact: false };
expect(filePanelSwitcherState(openWith(2), wide)?.layout).toBe("segmented");
expect(filePanelSwitcherState(openWith(3), wide)?.layout).toBe("segmented");
expect(filePanelSwitcherState(openWith(4), wide)).toMatchObject({
layout: "menu",
activePanelId: "panel-1",
candidates: openWith(4).candidates,
});
});
it("sets two panels side by side on a compact screen and more behind a menu", () => {
const compact = { compact: true };
expect(filePanelSwitcherState(openWith(2), compact)?.layout).toBe("segmented");
expect(filePanelSwitcherState(openWith(3), compact)?.layout).toBe("menu");
});
});
@@ -0,0 +1,24 @@
import type { FilePanelOpenWithState } from "./model";
/** Up to this many panels sit side by side in the bar; more go behind a menu. */
const SEGMENTED_PANEL_LIMIT = { wide: 3, compact: 2 };
export interface FilePanelSwitcherState<Candidate> {
layout: "segmented" | "menu";
candidates: readonly Candidate[];
activePanelId: string;
}
/** The bar's panel switcher for a tab's choice; null when the file has one panel or none. */
export function filePanelSwitcherState<Candidate>(
openWith: FilePanelOpenWithState<Candidate> | null,
{ compact }: { compact: boolean },
): FilePanelSwitcherState<Candidate> | null {
if (!openWith) return null;
const limit = compact ? SEGMENTED_PANEL_LIMIT.compact : SEGMENTED_PANEL_LIMIT.wide;
return {
layout: openWith.candidates.length > limit ? "menu" : "segmented",
candidates: openWith.candidates,
activePanelId: openWith.activePanelId,
};
}
@@ -0,0 +1,138 @@
import { useCallback, useMemo } from "react";
import { Text } from "react-native";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { MenuItem } from "@/components/ui/menu";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
ToolbarLabelTriggerIcon,
isToolbarLabelTriggerHighlighted,
toolbarLabelTriggerStyle,
toolbarLabelTriggerTextStyle,
} from "@/components/ui/toolbar-label-trigger";
import { useIsCompactFormFactor } from "@/constants/layout";
import { PluginIconView } from "@/plugins/icons";
import type { Theme } from "@/styles/theme";
import type { FilePanel } from "../panels";
import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook";
import { filePanelIcon, filePanelTitle } from "./presentation";
import { filePanelSwitcherState, type FilePanelSwitcherState } from "./switcher-layout";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedPanelIcon = withUnistyles(PluginIconView);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** The file bar's panel switcher. Renders nothing unless the file has more than one panel. */
export function FilePanelSwitcher({ choice }: { choice: FilePanelChoice }) {
const { openWith } = useFilePanelChoiceSnapshot(choice);
const compact = useIsCompactFormFactor();
const switcher = filePanelSwitcherState(openWith, { compact });
const select = useCallback((panelId: string) => choice.openWith(panelId), [choice]);
if (!switcher) return null;
return switcher.layout === "segmented" ? (
<SegmentedSwitcher switcher={switcher} onSelect={select} />
) : (
<MenuSwitcher switcher={switcher} onSelect={select} />
);
}
interface SwitcherProps {
switcher: FilePanelSwitcherState<FilePanel>;
onSelect(panelId: string): void;
}
function SegmentedSwitcher({ switcher, onSelect }: SwitcherProps) {
const { t } = useTranslation();
const options = useMemo(
() =>
switcher.candidates.map((panel) => ({
value: panel.id,
label: filePanelTitle(t, panel),
testID: `file-panel-switcher-${panel.id}`,
})),
[switcher.candidates, t],
);
return (
<SegmentedControl
size="xs"
value={switcher.activePanelId}
onValueChange={onSelect}
options={options}
testID="file-panel-switcher"
/>
);
}
function MenuSwitcher({ switcher, onSelect }: SwitcherProps) {
const { t } = useTranslation();
const active = switcher.candidates.find((panel) => panel.id === switcher.activePanelId);
const activeTitle = active ? filePanelTitle(t, active) : "";
return (
<DropdownMenu>
<DropdownMenuTrigger
style={toolbarLabelTriggerStyle}
accessibilityRole="button"
accessibilityLabel={t("panels.file.filePanels.openWith")}
testID="file-panel-switcher"
>
{(state) => (
<>
<Text
style={toolbarLabelTriggerTextStyle(isToolbarLabelTriggerHighlighted(state))}
numberOfLines={1}
>
{activeTitle}
</Text>
<ToolbarLabelTriggerIcon>
<ThemedChevronDown size={12} uniProps={extraMutedIconColorMapping} />
</ToolbarLabelTriggerIcon>
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="file-panel-switcher-menu">
{switcher.candidates.map((panel) => (
<SwitcherMenuItem
key={panel.id}
panel={panel}
selected={panel.id === switcher.activePanelId}
onSelect={onSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function SwitcherMenuItem({
panel,
selected,
onSelect,
}: {
panel: FilePanel;
selected: boolean;
onSelect(panelId: string): void;
}) {
const { t } = useTranslation();
const handleSelect = useCallback(() => onSelect(panel.id), [onSelect, panel.id]);
const leading = useMemo(
() => <ThemedPanelIcon icon={filePanelIcon(panel)} size={16} uniProps={mutedIconMapping} />,
[panel],
);
return (
<MenuItem
selected={selected}
leading={leading}
onSelect={handleSelect}
testID={`file-panel-switcher-${panel.id}`}
>
{filePanelTitle(t, panel)}
</MenuItem>
);
}
@@ -0,0 +1,39 @@
import { useCallback, useMemo, useRef } from "react";
import { WebView } from "react-native-webview";
const frameStyle = { flex: 1, backgroundColor: "white" };
const originWhitelist = ["*"];
export function DocumentFrame({ url, testID }: { url: string; testID?: string }) {
const source = useMemo(() => ({ uri: url }), [url]);
const loaded = useRef<string | null>(null);
const allowDocument = useCallback(
(request: { url: string }) => {
if (!url.startsWith("file:") || request.url !== url || loaded.current === url) return false;
loaded.current = url;
return true;
},
[url],
);
if (!url.startsWith("file:")) return null;
return (
<WebView
testID={testID}
style={frameStyle}
source={source}
originWhitelist={originWhitelist}
onShouldStartLoadWithRequest={allowDocument}
javaScriptEnabled={false}
setSupportMultipleWindows={false}
javaScriptCanOpenWindowsAutomatically={false}
domStorageEnabled={false}
thirdPartyCookiesEnabled={false}
cacheEnabled={false}
incognito
allowFileAccess
allowFileAccessFromFileURLs={false}
allowUniversalAccessFromFileURLs={false}
allowingReadAccessToURL={url}
/>
);
}
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { documentFrameSandbox } from "./sandbox";
const frameStyle = { flex: 1, minHeight: 0, border: "none", backgroundColor: "white" } as const;
interface ResolvedDocument {
url: string;
sandbox: string | null;
}
export function DocumentFrame({ url, testID }: { url: string; testID?: string }) {
const [document, setDocument] = useState<ResolvedDocument | null>(null);
useEffect(() => {
if (!url.startsWith("blob:")) return;
const lifetime = new AbortController();
void fetch(url, { signal: lifetime.signal })
.then(async (response) => {
const sandbox = documentFrameSandbox(response.headers.get("content-type"));
await response.body?.cancel();
if (!lifetime.signal.aborted) setDocument({ url, sandbox });
return undefined;
})
.catch(() => {
if (!lifetime.signal.aborted) setDocument({ url, sandbox: "" });
});
return () => lifetime.abort();
}, [url]);
if (!url.startsWith("blob:") || document?.url !== url) return null;
return (
<iframe
title="Document preview"
data-testid={testID}
src={url}
sandbox={document.sandbox ?? undefined}
referrerPolicy="no-referrer"
style={frameStyle}
/>
);
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { documentFrameSandbox } from "./sandbox";
describe("document frame sandbox", () => {
it("lets the browser's PDF viewer render PDF documents", () => {
expect(documentFrameSandbox("application/pdf")).toBeNull();
expect(documentFrameSandbox("Application/PDF; charset=binary")).toBeNull();
});
it.each(["text/html", "application/octet-stream", "unknown", "", null, undefined])(
"keeps %s fully sandboxed",
(mediaType) => {
expect(documentFrameSandbox(mediaType)).toBe("");
},
);
});
@@ -0,0 +1,9 @@
/**
* Chromium refuses to run its PDF viewer in a sandboxed frame, regardless of tokens.
* The viewer is a browser component, not page script. The host sets the blob's type from
* the daemon's MIME type, so HTML bytes labeled PDF fail to parse in the viewer rather
* than executing. HTML and every other media type retain the full sandbox.
*/
export function documentFrameSandbox(mediaType: string | null | undefined): string | null {
return mediaType?.split(";")[0].trim().toLowerCase() === "application/pdf" ? null : "";
}
@@ -0,0 +1,73 @@
import { expect, it } from "vitest";
import { FileEditorModel, type FileEditorClock } from "@/file-pane/editor/model";
import { textDocument } from "./document";
class DocumentClock implements FileEditorClock {
callback: (() => void) | null = null;
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
this.callback = callback;
return 1 as unknown as ReturnType<typeof setTimeout>;
}
clearTimeout() {
this.callback = null;
}
fire() {
const callback = this.callback;
this.callback = null;
callback?.();
}
}
it("plugin text edits mark the host document dirty and autosave with its revision", async () => {
const clock = new DocumentClock();
const writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> =
[];
const model = new FileEditorModel({
file: {
content: "original",
hasBom: false,
version: {
status: "ready",
cwd: "/workspace",
path: "notes.md",
size: 8,
modifiedAt: "2026-10-19T00:00:00Z",
revision: "first",
},
},
clock,
session: {
async write(input) {
writes.push(input);
return {
status: "written",
modifiedAt: "2026-10-19T00:00:01Z",
size: input.content.length,
revision: "second",
};
},
},
});
const document = textDocument(model.getSnapshot().content, (next) => model.edit(next));
expect(document.editable).toBe(true);
document.setText("edited");
expect(model.getSnapshot()).toMatchObject({ status: "dirty", modified: true, content: "edited" });
expect(writes).toEqual([]);
clock.fire();
await Promise.resolve();
expect(writes).toEqual([
{ content: "edited", expectedModifiedAt: "2026-10-19T00:00:00Z", expectedRevision: "first" },
]);
expect(model.getSnapshot()).toMatchObject({
status: "clean",
modified: false,
content: "edited",
});
model.dispose();
});
it("readonly text documents leave the host text unchanged", () => {
const document = textDocument("readonly");
document.setText("ignored");
expect(document).toMatchObject({ kind: "text", text: "readonly", editable: false });
});
+16
View File
@@ -0,0 +1,16 @@
import type { PluginBytesDocument, PluginTextDocument } from "@getpaseo/plugin/client";
export function textDocument(text: string, setText?: (text: string) => void): PluginTextDocument {
return {
kind: "text",
text,
editable: Boolean(setText),
setText(next) {
setText?.(next);
},
};
}
export function bytesDocument(bytes: Uint8Array, url: string): PluginBytesDocument {
return { kind: "bytes", bytes, url };
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { withPreviewCsp } from "@/file-pane/html-preview-csp";
import { withPreviewCsp } from "./html-preview-csp";
describe("withPreviewCsp", () => {
it("places the policy before the complete source document", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { htmlPreviewNavigationKind } from "@/file-pane/html-preview-navigation";
import { htmlPreviewNavigationKind } from "./html-preview-navigation";
describe("htmlPreviewNavigationKind", () => {
it("allows the WebView's initial document URLs", () => {
@@ -29,7 +29,7 @@ export function FileHtmlPreview({ html, testID }: { html: string; testID?: strin
return (
<iframe
data-testid={testID}
title={t("panels.file.editor.preview")}
title={t("panels.file.filePanels.builtin.html")}
srcDoc={document}
sandbox={SANDBOX}
referrerPolicy="no-referrer"
+15
View File
@@ -0,0 +1,15 @@
export { isSourceFilePanel } from "./catalog";
export { FilePanelView } from "./view";
export type { FilePanel } from "./panels";
export { useFilePanelChoice, type FilePanelChoice } from "./choice/hook";
export { FilePanelPicker } from "./choice/picker";
export { FilePanelSwitcher } from "./choice/switcher";
export {
FilePanelBarMenu,
useFilePanelOpenWithMenu,
type FilePanelOpenWithMenu,
} from "./choice/open-with";
export { DocumentFrame } from "./document-frame";
export { filePanelIcon, filePanelOptionLabel, filePanelSelectorLabel } from "./choice/presentation";
export { useFilePanelSettings, type FilePanelSettings } from "./settings/hook";
export type { FilePanelSettingsRow } from "./settings/rows";
+45
View File
@@ -0,0 +1,45 @@
import { useMemo } from "react";
import { useInstalledPlugins } from "@/plugins/registry";
import type { InstalledPlugin, EvaluatedPluginFilePanelContribution } from "@/plugins/types";
import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog";
export interface PluginFilePanel {
id: string;
builtin: false;
files: EvaluatedPluginFilePanelContribution["files"];
document: "text" | "bytes";
maxBytes: number;
plugin: InstalledPlugin;
contribution: EvaluatedPluginFilePanelContribution;
}
export type FilePanel = RenderingFilePanel | PluginFilePanel | SourceFilePanel;
export function useFilePanels(serverId: string): readonly FilePanel[] {
const plugins = useInstalledPlugins();
return useMemo(
() => hostFilePanels(plugins.filter((plugin) => plugin.serverId === serverId)),
[plugins, serverId],
);
}
/** Every host's panels; a plugin installed on several hosts contributes its panels once per host. */
export function useAllHostsFilePanels(): readonly FilePanel[] {
const plugins = useInstalledPlugins();
return useMemo(() => hostFilePanels(plugins), [plugins]);
}
function hostFilePanels(plugins: readonly InstalledPlugin[]): readonly FilePanel[] {
return filePanelCatalog<PluginFilePanel>(
plugins.flatMap((plugin) =>
plugin.filePanels.map((contribution) => ({
id: `plugin:${plugin.id}/${contribution.id}`,
builtin: false,
files: contribution.files,
document: contribution.document,
maxBytes: contribution.maxBytes,
plugin,
contribution,
})),
),
);
}
@@ -0,0 +1,94 @@
import { useMemo } from "react";
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import type { PluginTheme } from "@getpaseo/plugin";
import { withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePaneContext } from "@/panels/pane-context";
import { useHosts } from "@/runtime/host-runtime";
import { createPluginClientStateSource } from "@/plugins/client-state/source";
import { usePluginHostNavigation } from "@/plugins/host-navigation";
import { PluginInstallationProvider } from "@/plugins/installation-provider";
import { SurfaceErrorBoundary } from "@/plugins/surface-error-boundary";
import { resolvePluginPlatform } from "@/plugins/platform";
import { toPluginTheme } from "@/plugins/theme";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import type { Theme } from "@/styles/theme";
import type { ExplorerFile } from "@/stores/session-store";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { filePanelExtension } from "./resolve";
import type { PluginFilePanel } from "./panels";
interface Props {
workspaceRoot: string;
panel: PluginFilePanel;
preview: ExplorerFile;
location: WorkspaceFileLocation;
navigationRevision: number;
document: PluginFilePanelProps["document"];
}
function PluginHost({
workspaceRoot,
panel,
preview,
location,
navigationRevision,
document,
theme,
}: Props & { theme: PluginTheme }) {
const { workspaceId } = usePaneContext();
const { plugin, contribution } = panel;
const serverId = plugin.serverId;
const compact = useIsCompactFormFactor();
const hosts = useHosts();
const navigation = usePluginHostNavigation(serverId);
const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [serverId, hostLabel]);
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
const file = useMemo(
() => ({
path: buildAbsoluteExplorerPath({ workspaceRoot, entryPath: location.path }),
name: location.path.split(/[\\/]/).pop() ?? "",
extension: filePanelExtension(location.path),
mimeType: preview.mimeType ?? "application/octet-stream",
size: preview.size,
modifiedAt: preview.modifiedAt,
lineStart: location.lineStart,
lineEnd: location.lineEnd,
}),
[
workspaceRoot,
location.path,
location.lineStart,
location.lineEnd,
preview.mimeType,
preview.size,
preview.modifiedAt,
],
);
const Component = contribution.Component;
return (
<SurfaceErrorBoundary installation={plugin} Surface={Component} key={panel.id}>
<PluginInstallationProvider plugin={plugin}>
<PluginClientStateProvider source={stateSource}>
<Component
theme={theme}
host={host}
layout={layout}
navigation={navigation}
workspaceId={workspaceId}
navigationRevision={navigationRevision}
document={document}
file={file}
/>
</PluginClientStateProvider>
</PluginInstallationProvider>
</SurfaceErrorBoundary>
);
}
const ThemedHost = withUnistyles(PluginHost);
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function PluginFilePanelHost(props: Props) {
return <ThemedHost {...props} uniProps={themeMapping} />;
}
@@ -0,0 +1,230 @@
import { createFilePanePreview } from "@/file-pane/preview-lifecycle/model";
import { filePanelCatalog } from "./catalog";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { resolveFileReadPanels } from "./resolve";
import { describe, expect, it } from "vitest";
import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
const markdown: FilePanelCandidate = {
id: "builtin:markdown",
builtin: true,
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
};
const alternative: FilePanelCandidate = {
...markdown,
id: "plugin:notes/markdown",
builtin: false,
};
const file: FilePanelFile = { path: "/docs/README.MD", kind: "text", size: 100, hasBytes: true };
function resolve(
overrides: Partial<Parameters<typeof resolveFilePanel<FilePanelCandidate>>[0]> = {},
) {
return resolveFilePanel({
file,
candidates: [markdown, alternative],
associations: {},
...overrides,
});
}
describe("resolveFilePanel", () => {
it("uses the associated candidate before catalog order", () => {
expect(resolve({ associations: { "ext:md": alternative.id } })).toEqual({
panel: alternative,
candidates: [markdown, alternative],
selectorKey: "ext:md",
associatedPanelId: alternative.id,
});
});
it("ignores an absent association without deleting it", () => {
const associations = { "ext:md": "plugin:absent/markdown" };
expect(resolve({ associations }).panel).toBe(markdown);
expect(associations).toEqual({ "ext:md": "plugin:absent/markdown" });
});
it("uses a single candidate and preserves catalog order for multiple candidates", () => {
expect(resolve({ candidates: [alternative] }).panel).toBe(alternative);
expect(resolve().panel).toBe(markdown);
expect(resolve({ candidates: [alternative, markdown] }).panel).toBe(alternative);
});
it("uses a tab choice before the association and ignores a choice that is not a candidate", () => {
const associations = { "ext:md": alternative.id };
expect(resolve({ associations, choice: markdown.id })).toMatchObject({
panel: markdown,
associatedPanelId: alternative.id,
});
expect(resolve({ associations, choice: "plugin:absent/markdown" }).panel).toBe(alternative);
});
it("returns the floor when no selector matches", () => {
expect(resolve({ file: { ...file, path: "index.ts" } })).toEqual({
panel: null,
candidates: [],
selectorKey: "ext:ts",
associatedPanelId: null,
});
});
it("offers a panel claiming every file wherever its document is available", () => {
const every: FilePanelCandidate = { ...markdown, id: "builtin:source", files: "all" };
expect(resolve({ file: { ...file, path: "index.ts" }, candidates: [every] })).toEqual({
panel: every,
candidates: [every],
selectorKey: "ext:ts",
associatedPanelId: null,
});
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [every] }).panel).toBeNull();
});
it("filters text panels for binary files even with an association", () => {
expect(
resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel,
).toBeNull();
});
it("requires bytes for bytes panels and accepts binary files with bytes", () => {
const bytes: FilePanelCandidate = { ...alternative, document: "bytes" };
expect(resolve({ file: { ...file, hasBytes: false }, candidates: [bytes] }).panel).toBeNull();
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [bytes] }).panel).toBe(bytes);
});
it("includes the maxBytes boundary and skips oversized associated panels", () => {
const limited = { ...alternative, maxBytes: 100 };
expect(resolve({ candidates: [limited] }).panel).toBe(limited);
expect(
resolve({
file: { ...file, size: 101 },
candidates: [limited, markdown],
associations: { "ext:md": limited.id },
}).panel,
).toBe(markdown);
});
it("uses exact basename keys when an eligible candidate matches by filename", () => {
const named = { ...alternative, files: { extensions: [], filenames: ["README.MD"] } };
expect(
resolve({ candidates: [markdown, named], associations: { "name:README.MD": named.id } }),
).toEqual({
panel: named,
candidates: [markdown, named],
selectorKey: "name:README.MD",
associatedPanelId: named.id,
});
expect(resolve({ file: { ...file, path: "README.md" }, candidates: [named] }).panel).toBeNull();
});
it("uses extension keys when filename candidates were filtered out", () => {
const named = {
...alternative,
maxBytes: 1,
files: { extensions: [], filenames: ["README.MD"] },
};
expect(resolve({ candidates: [named, markdown] }).selectorKey).toBe("ext:md");
});
it.each(["/src/Dockerfile", "C:\\src\\Dockerfile"])(
"matches an extensionless exact filename: %s",
(path) => {
const named = { ...alternative, files: { extensions: [], filenames: ["Dockerfile"] } };
expect(resolve({ file: { ...file, path }, candidates: [named] })).toEqual({
panel: named,
candidates: [named],
selectorKey: "name:Dockerfile",
associatedPanelId: null,
});
},
);
it("normalizes extension selectors and keeps extensionless files distinct", () => {
const upper = { ...alternative, files: { extensions: ["MD"] } };
expect(resolve({ candidates: [upper] }).panel).toBe(upper);
expect(resolve({ file: { ...file, path: "README" } }).selectorKey).toBe("ext:");
});
});
it("opens a binary PDF with its plugin and filters oversized and unavailable documents", () => {
const pdf: FilePanelCandidate = {
id: "plugin:pdf/viewer",
builtin: false,
files: { extensions: ["pdf"] },
document: "bytes",
maxBytes: 1024,
};
const pdfFile: FilePanelFile = {
path: "/workspace/document.pdf",
kind: "binary",
size: 1024,
hasBytes: true,
};
expect(
resolveFilePanel({ file: pdfFile, candidates: [markdown, pdf], associations: {} }).panel,
).toBe(pdf);
expect(
resolveFilePanel({
file: { ...pdfFile, size: 1025 },
candidates: [pdf],
associations: { "ext:pdf": pdf.id },
}).panel,
).toBeNull();
});
describe("resolved panel resources", () => {
const attachment: AttachmentMetadata = {
id: "preview",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "preview",
createdAt: 0,
};
function read(path: string, kind: FileReadResult["kind"]): FileReadResult {
return {
path,
kind,
bytes: new TextEncoder().encode("contents"),
mime: "text/plain",
size: 8,
modifiedAt: "2026-10-19T00:00:00Z",
};
}
it.each([
["notes.md", "text"],
["source.ts", "text"],
["unknown.bin", "binary"],
] as const)(
"does not create an attachment for %s, including live refreshes",
async (path, kind) => {
const writes: Uint8Array[] = [];
const persist = async (input: { bytes: Uint8Array }) => {
writes.push(input.bytes);
return attachment;
};
for (const modifiedAt of ["2026-10-19T00:00:00Z", "2026-10-19T00:00:01Z"]) {
const raw = { ...read(path, kind), modifiedAt };
const panel =
resolveFileReadPanels({
file: raw,
path,
candidates: filePanelCatalog([]),
associations: {},
})?.panel ?? null;
const preview = await createFilePanePreview(raw, panel, persist);
expect(preview?.bytesPreview).toBeNull();
expect(preview?.file).not.toHaveProperty("bytes");
}
expect(writes).toEqual([]);
},
);
it("persists and retains bytes only for the selected image panel", async () => {
const raw = read("picture.png", "image");
const panel =
resolveFileReadPanels({
file: raw,
path: raw.path,
candidates: filePanelCatalog([]),
associations: {},
})?.panel ?? null;
const writes: Uint8Array[] = [];
const preview = await createFilePanePreview(raw, panel, async (input) => {
writes.push(input.bytes);
return attachment;
});
expect(panel?.id).toBe("builtin:image");
expect(writes).toEqual([raw.bytes]);
expect(preview?.bytesPreview).toEqual({ bytes: raw.bytes, attachment });
expect(preview?.file).not.toHaveProperty("bytes");
});
});
+149
View File
@@ -0,0 +1,149 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
export interface FilePanelFile {
path: string;
kind: "text" | "image" | "binary";
size: number;
hasBytes: boolean;
}
export interface FilePanelSelector {
extensions: readonly string[];
filenames?: readonly string[];
}
/** A selector, or `"all"`: every file whose document the panel can read. */
export type FilePanelFiles = FilePanelSelector | "all";
export interface FilePanelCandidate {
id: string;
/** Paseo's own panel. Paseo asks which panel opens a file only when a plugin panel claims it. */
builtin: boolean;
files: FilePanelFiles;
document: "text" | "bytes";
maxBytes: number;
}
export interface FilePanelResolution<Candidate> {
panel: Candidate | null;
candidates: readonly Candidate[];
selectorKey: string;
/** The remembered candidate for `selectorKey`, when this host has it. */
associatedPanelId: string | null;
}
export function filePanelExtension(path: string): string {
const name = filePanelBasename(path);
const dot = name.lastIndexOf(".");
return dot < 0 ? "" : name.slice(dot + 1).toLowerCase();
}
function filePanelBasename(path: string): string {
return path.trim().split(/[\\/]/).pop() ?? "";
}
/** Which files an association applies to: one extension, or one exact filename. */
export interface FilePanelSelectorKey {
kind: "ext" | "name";
value: string;
}
function filePanelSelectorKey(key: FilePanelSelectorKey): string {
return `${key.kind}:${key.value}`;
}
export function parseFilePanelSelectorKey(selectorKey: string): FilePanelSelectorKey {
const separator = selectorKey.indexOf(":");
const kind = selectorKey.slice(0, separator) === "name" ? "name" : "ext";
return { kind, value: selectorKey.slice(separator + 1) };
}
function matchesFiles(file: FilePanelFile, files: FilePanelFiles): boolean {
if (files === "all") return true;
return matchesName(filePanelBasename(file.path), filePanelExtension(file.path), files);
}
function claimsFilename(files: FilePanelFiles, filename: string): boolean {
return files !== "all" && Boolean(files.filenames?.includes(filename));
}
function matchesName(name: string, extension: string, selector: FilePanelSelector): boolean {
if (selector.filenames?.includes(name)) return true;
return selector.extensions.some((entry) => entry.toLowerCase() === extension);
}
/** The selector keys a panel claims by name: one per extension and one per filename. */
export function filePanelClaimedSelectorKeys(files: FilePanelFiles): string[] {
if (files === "all") return [];
const selector = files;
return [
...selector.extensions.map((extension) =>
filePanelSelectorKey({ kind: "ext", value: extension.toLowerCase() }),
),
...(selector.filenames ?? []).map((filename) =>
filePanelSelectorKey({ kind: "name", value: filename }),
),
];
}
/**
* Whether a panel is offered for files under `selectorKey`, by the resolver's matching rule. Size
* and document kind depend on the file, so they are not part of the answer.
*/
export function claimsFilePanelSelectorKey(
selector: FilePanelSelector,
selectorKey: string,
): boolean {
const { kind, value } = parseFilePanelSelectorKey(selectorKey);
return kind === "name"
? matchesName(value, filePanelExtension(value), selector)
: matchesName("", value, selector);
}
/** Paseo asks which panel opens a file only when a plugin panel is one of several candidates. */
export function asksForFilePanel(candidates: readonly FilePanelCandidate[]): boolean {
return candidates.length > 1 && candidates.some((candidate) => !candidate.builtin);
}
export function resolveFilePanel<Candidate extends FilePanelCandidate>(input: {
file: FilePanelFile;
candidates: readonly Candidate[];
associations: Readonly<Record<string, string>>;
/** A panel chosen for this tab open; wins over the association while it is a candidate. */
choice?: string | null;
}): FilePanelResolution<Candidate> {
const { file, associations } = input;
const candidates = input.candidates.filter((candidate) => {
const documentAvailable = candidate.document === "text" ? file.kind === "text" : file.hasBytes;
return (
documentAvailable && file.size <= candidate.maxBytes && matchesFiles(file, candidate.files)
);
});
const filename = filePanelBasename(file.path);
const matchedFilename = candidates.some((candidate) => claimsFilename(candidate.files, filename));
const selectorKey = matchedFilename
? filePanelSelectorKey({ kind: "name", value: filename })
: filePanelSelectorKey({ kind: "ext", value: filePanelExtension(file.path) });
const associated = candidates.find((candidate) => candidate.id === associations[selectorKey]);
const chosen = candidates.find((candidate) => candidate.id === input.choice);
const panel = chosen ?? associated ?? candidates[0] ?? null;
return { panel, candidates, selectorKey, associatedPanelId: associated?.id ?? null };
}
/** Raw reads always carry bytes, independently of whether a panel will retain them. */
export function resolveFileReadPanels<Candidate extends FilePanelCandidate>(input: {
file: FileReadResult | null;
path: string;
candidates: readonly Candidate[];
associations: Readonly<Record<string, string>>;
choice?: string | null;
}): FilePanelResolution<Candidate> | null {
const { file, path } = input;
if (!file) return null;
return resolveFilePanel({
file: { path, kind: file.kind, size: file.size, hasBytes: true },
candidates: input.candidates,
associations: input.associations,
choice: input.choice,
});
}
@@ -0,0 +1,24 @@
import { useCallback, useMemo } from "react";
import { useFilePanelAssociations } from "../associations";
import { useAllHostsFilePanels, type FilePanel } from "../panels";
import { buildFilePanelSettingsRows, type FilePanelSettingsRow } from "./rows";
export interface FilePanelSettings {
rows: readonly FilePanelSettingsRow<FilePanel>[];
/** Remembers `panelId` for the key; the next open of a matching file uses it. */
choose(selectorKey: string, panelId: string): void;
/** Forgets the key's panel, so the next open of a matching file asks. */
askAgain(selectorKey: string): void;
}
/** Settings > Files: the keys worth configuring across every host, and the commands on them. */
export function useFilePanelSettings(): FilePanelSettings {
const candidates = useAllHostsFilePanels();
const { associations, remember } = useFilePanelAssociations();
const rows = useMemo(
() => buildFilePanelSettingsRows({ candidates, associations }),
[associations, candidates],
);
const askAgain = useCallback((selectorKey: string) => remember(selectorKey, null), [remember]);
return { rows, choose: remember, askAgain };
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { filePanelCatalog, sourceFilePanel } from "../catalog";
import type { FilePanelCandidate } from "../resolve";
import { buildFilePanelSettingsRows } from "./rows";
type HostedCandidate = FilePanelCandidate & { serverId?: string };
const builtinMarkdown: HostedCandidate = filePanelCatalog([])[0];
const source: HostedCandidate = sourceFilePanel;
/** What Settings reads: the built-ins and every host's plugin panels, in catalog order. */
function catalog(...plugins: HostedCandidate[]): HostedCandidate[] {
return [...filePanelCatalog(plugins)];
}
function richMarkdown(serverId: string): HostedCandidate {
return {
id: "plugin:markdown-panel/rich-markdown",
builtin: false,
files: { extensions: ["MD"] },
document: "text",
maxBytes: 10 * 1024 * 1024,
serverId,
};
}
const dockerfile: HostedCandidate = {
id: "plugin:docker/dockerfile",
builtin: false,
files: { extensions: [], filenames: ["Dockerfile"] },
document: "text",
maxBytes: Infinity,
serverId: "host-a",
};
function rows(candidates: HostedCandidate[], associations: Record<string, string> = {}) {
return buildFilePanelSettingsRows({ candidates, associations });
}
describe("buildFilePanelSettingsRows", () => {
it("has nothing to configure when only built-in panels claim a key", () => {
expect(rows(catalog())).toEqual([]);
});
it("lists a key a plugin panel claims, built-in first and Source last", () => {
const plugin = richMarkdown("host-a");
expect(rows(catalog(plugin))).toEqual([
{
selectorKey: "ext:md",
candidates: [builtinMarkdown, plugin, source],
associatedPanelId: null,
},
]);
});
it("offers Source only on keys a text panel claims, and asks only between several", () => {
const pdf: HostedCandidate = {
id: "plugin:pdf-panel/pdf",
builtin: false,
files: { extensions: ["pdf"] },
document: "bytes",
maxBytes: Infinity,
};
expect(rows(catalog(pdf))).toEqual([]);
expect(rows(catalog(pdf), { "ext:pdf": pdf.id })).toEqual([
{ selectorKey: "ext:pdf", candidates: [pdf], associatedPanelId: pdf.id },
]);
});
it("offers a panel installed on several hosts once, as the first host has it", () => {
const first = richMarkdown("host-a");
const [row] = rows(catalog(first, richMarkdown("host-b")));
expect(row?.candidates).toEqual([builtinMarkdown, first, source]);
});
it("keeps a remembered key that only built-in panels claim", () => {
expect(rows(catalog(), { "ext:markdown": source.id })).toEqual([
{
selectorKey: "ext:markdown",
candidates: [builtinMarkdown, source],
associatedPanelId: source.id,
},
]);
});
it("keeps a remembered key whose panel is on no connected host", () => {
expect(rows(catalog(), { "ext:pdf": "plugin:pdf-panel/pdf" })).toEqual([
{ selectorKey: "ext:pdf", candidates: [], associatedPanelId: "plugin:pdf-panel/pdf" },
]);
});
it("offers extension panels on a filename key the way the resolver does", () => {
const named: HostedCandidate = {
...dockerfile,
id: "plugin:readme/readme",
files: { extensions: [], filenames: ["README.md"] },
};
const keys = rows(catalog(named)).map((row) => row.selectorKey);
expect(keys).toEqual(["name:README.md"]);
expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named, source]);
});
it("orders extensions before filenames, each alphabetically", () => {
const other: HostedCandidate = { ...dockerfile, id: "plugin:other/dockerfile" };
const html: HostedCandidate = {
...richMarkdown("host-a"),
id: "plugin:web/html",
files: { extensions: ["html"] },
};
const keys = rows(catalog(dockerfile, other, html, richMarkdown("host-a"))).map(
(row) => row.selectorKey,
);
expect(keys).toEqual(["ext:html", "ext:md", "name:Dockerfile"]);
});
});
@@ -0,0 +1,78 @@
import {
asksForFilePanel,
claimsFilePanelSelectorKey,
filePanelClaimedSelectorKeys,
parseFilePanelSelectorKey,
type FilePanelCandidate,
} from "../resolve";
/** One selector key in Settings > Files. */
export interface FilePanelSettingsRow<Candidate> {
selectorKey: string;
/** Every known panel for the key, coalesced across hosts, in resolver order. */
candidates: readonly Candidate[];
/** The remembered panel, which may be on no connected host; null means Paseo asks on open. */
associatedPanelId: string | null;
}
/**
* The keys worth configuring: every key where Paseo would ask, and every key that has an
* association, so a remembered choice can always be changed or forgotten. A panel installed on
* several hosts is one candidate.
*/
export function buildFilePanelSettingsRows<Candidate extends FilePanelCandidate>(input: {
candidates: readonly Candidate[];
associations: Readonly<Record<string, string>>;
}): FilePanelSettingsRow<Candidate>[] {
const candidates = coalesceById(input.candidates);
const keys = new Set([
...candidates.flatMap((candidate) => filePanelClaimedSelectorKeys(candidate.files)),
...Object.keys(input.associations),
]);
return [...keys]
.sort(compareSelectorKeys)
.map((selectorKey) => ({
selectorKey,
candidates: offeredCandidates(candidates, selectorKey),
associatedPanelId: input.associations[selectorKey] ?? null,
}))
.filter((row) => asksForFilePanel(row.candidates) || row.associatedPanelId !== null);
}
/**
* The panels offered under a key, by the resolver's matching rule. A panel claiming every file
* (Source) is offered where a panel claiming the key reads the same document: that is the only
* knowledge of whether the key's files are text.
*/
function offeredCandidates<Candidate extends FilePanelCandidate>(
candidates: readonly Candidate[],
selectorKey: string,
): Candidate[] {
const claiming = candidates.filter(
(candidate) =>
candidate.files !== "all" && claimsFilePanelSelectorKey(candidate.files, selectorKey),
);
return candidates.filter((candidate) =>
candidate.files === "all"
? claiming.some((claimant) => claimant.document === candidate.document)
: claiming.includes(candidate),
);
}
function coalesceById<Candidate extends { id: string }>(
candidates: readonly Candidate[],
): Candidate[] {
const byId = new Map<string, Candidate>();
for (const candidate of candidates) {
if (!byId.has(candidate.id)) byId.set(candidate.id, candidate);
}
return [...byId.values()];
}
/** Extensions before filenames, each alphabetically. */
function compareSelectorKeys(left: string, right: string): number {
const a = parseFilePanelSelectorKey(left);
const b = parseFilePanelSelectorKey(right);
if (a.kind !== b.kind) return a.kind === "ext" ? -1 : 1;
return a.value.localeCompare(b.value);
}
+38
View File
@@ -0,0 +1,38 @@
import type { ComponentProps } from "react";
import type { ExplorerFile } from "@/stores/session-store";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FilePanel } from "./panels";
import type { SourceFilePanel } from "./catalog";
import { BuiltinFilePanelView } from "./builtin";
import { PluginFilePanelHost } from "./plugin-host";
import { bytesDocument, textDocument } from "./document";
/** Renders a file panel as the file tab's body. Source is the tab's own editor, not hosted here. */
export function FilePanelView(
props: Omit<ComponentProps<typeof BuiltinFilePanelView>, "panel"> & {
panel: Exclude<FilePanel, SourceFilePanel>;
preview: ExplorerFile;
bytes: Uint8Array | null;
location: WorkspaceFileLocation;
navigationRevision: number;
setText?: (text: string) => void;
},
) {
const { panel, preview } = props;
if (panel.builtin) return <BuiltinFilePanelView {...props} panel={panel} />;
let document;
if (panel.document === "text") document = textDocument(props.content, props.setText);
else if (props.bytes && props.documentPreviewUri)
document = bytesDocument(props.bytes, props.documentPreviewUri);
if (!document) return null;
return (
<PluginFilePanelHost
workspaceRoot={props.workspaceRoot}
panel={panel}
preview={preview}
location={props.location}
navigationRevision={props.navigationRevision}
document={document}
/>
);
}
@@ -15,7 +15,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { GitAction, GitActions } from "@/git/policy";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import { useGitActionRunner } from "@/git/use-actions";
import { buttonControlHeight, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
@@ -129,14 +129,10 @@ export function GitActionsSplitButton({
[theme.colors.surface2],
);
const menuOnlyActions = useMemo(
() => [
...(gitActions.primary ? [gitActions.primary] : []),
...gitActions.secondary,
...gitActions.menu,
],
[gitActions.menu, gitActions.primary, gitActions.secondary],
);
const menuOnlyActions = [
...(gitActions.primary ? [gitActions.primary] : []),
...actionsWithoutPrimary(gitActions),
];
if (menuOnly) {
if (menuOnlyActions.length === 0) {
+54 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
@@ -190,6 +190,59 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
});
it("drops a promoted remote action from the remaining menu actions", () => {
const pull = buildGitActions(createInput({ hasRemote: true, behindOfOrigin: 2 }));
expect(pull.primary?.id).toBe("pull");
expect(actionsWithoutPrimary(pull).map((action) => action.id)).toEqual([
"push",
"pull-and-push",
"archive-workspace",
]);
const push = buildGitActions(createInput({ hasRemote: true, aheadOfOrigin: 1 }));
expect(push.primary?.id).toBe("push");
expect(actionsWithoutPrimary(push).map((action) => action.id)).toEqual([
"pull",
"pull-and-push",
"archive-workspace",
]);
});
it("drops a promoted merge action from the remaining menu actions", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id: "merge-pr-merge", label: "Merge PR (merge)" });
expect(actionsWithoutPrimary(actions).map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"archive-workspace",
]);
});
it("keeps push clickable with a clearer message when the branch diverged", () => {
const actions = buildGitActions(
createInput({
+12
View File
@@ -312,6 +312,18 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
};
}
/**
* The actions a flattened list shows after the primary. `secondary` keeps the actions the policy
* can promote to `primary`, and the translator rebuilds those objects, so a flattening consumer
* has to drop the promoted one by id or it renders it twice.
*/
export function actionsWithoutPrimary(gitActions: GitActions): GitAction[] {
const primaryActionId = gitActions.primary?.id;
return [...gitActions.secondary, ...gitActions.menu].filter(
(action) => action.id !== primaryActionId,
);
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive) {
return "archive-workspace";
@@ -44,6 +44,33 @@ function makeDeps(
}
describe("loadAppSettingsFromStorage", () => {
it("persists file panel associations, including panels absent from this host", async () => {
const deps = makeDeps();
const queryClient = new QueryClient();
const filePanelAssociations = {
"ext:md": "builtin:markdown",
"name:Dockerfile": "plugin:absent/container",
};
await saveAppSettings({ queryClient, deps, updates: { filePanelAssociations } });
expect((await loadAppSettingsFromStorage(deps)).filePanelAssociations).toEqual(
filePanelAssociations,
);
});
it.each([undefined, null, [], "invalid", { "ext:md": 42 }])(
"defaults malformed file panel associations without discarding other settings: %j",
async (filePanelAssociations) => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ filePanelAssociations, vimKeybindings: true }),
}),
});
const settings = await loadAppSettingsFromStorage(deps);
expect(settings.filePanelAssociations).toEqual({});
expect(settings.vimKeybindings).toBe(true);
},
);
it("preserves a persisted steer send behavior", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -104,6 +104,7 @@ export interface AppSettings {
toolCallDetailLevel: ToolCallDetailLevel;
chatOutlineEnabled: boolean;
vimKeybindings: boolean;
filePanelAssociations: Record<string, string>;
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
openInSidePane: OpenInSidePanePreferences;
pullRequestOpenLocation: PullRequestOpenLocation;
@@ -160,6 +161,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
toolCallDetailLevel: "detailed",
chatOutlineEnabled: true,
vimKeybindings: false,
filePanelAssociations: {},
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
pullRequestOpenLocation: "explorer",
};
@@ -261,6 +263,7 @@ const StoredAppSettingsSchema = z
compactToolCalls: z.boolean().optional().catch(undefined),
chatOutlineEnabled: z.boolean().catch(true),
vimKeybindings: z.boolean().catch(false),
filePanelAssociations: z.record(z.string(), z.string()).catch({}),
openInSidePane: z
.object({
explorerFiles: z.boolean().catch(false),
+29 -2
View File
@@ -538,6 +538,7 @@ export const ar: TranslationResources = {
},
},
setup: {
updateHost: "حدّث المضيف لعرض تفاصيل إعداد مساحة العمل",
descriptor: {
label: "يثبت",
completed: "اكتمل الإعداد",
@@ -1917,6 +1918,24 @@ export const ar: TranslationResources = {
creatingAgent: "وكيل الخلق",
},
file: {
filePanels: {
pickerTitleExtension: "فتح ملفات .{{extension}} باستخدام",
pickerTitleFilename: "فتح {{filename}} باستخدام",
dontAskAgain: "لا تسألني مرة أخرى",
use: "استخدام",
openWith: "فتح باستخدام…",
alwaysUseExtension: "استخدام دائمًا لـ .{{extension}}",
alwaysUseFilename: "استخدام دائمًا لـ {{filename}}",
option: "{{title}} — {{owner}}",
actions: "إجراءات الملف",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "صورة",
source: "المصدر",
},
},
directoryMissing: "لم يتم العثور على دليل Workspace.",
loading: "جارٍ تحميل الملف...",
noPreview: "لا تتوفر معاينة",
@@ -1934,8 +1953,6 @@ export const ar: TranslationResources = {
changedOnDisk: "تغيّر على القرص",
vimMode: "وضع Vim ‏{{mode}}",
cursor: "السطر {{line}}، العمود {{column}}",
preview: "معاينة",
source: "المصدر",
deletedTitle: "تم حذف الملف من القرص",
checkFailedTitle: "تعذّر التحقق من الملف على القرص",
preservedDescription: "تم الاحتفاظ بالنسخة المفتوحة.",
@@ -2073,6 +2090,7 @@ export const ar: TranslationResources = {
terminal: "الطرفية",
browser: "المتصفح",
editor: "المحرر",
files: "الملفات",
shortcuts: "الاختصارات",
integrations: "التكامل",
notifications: "الإشعارات",
@@ -2086,6 +2104,14 @@ export const ar: TranslationResources = {
vimKeybindings: "اختصارات Vim",
vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
},
files: {
title: "الملفات",
askEveryTime: "اسأل في كل مرة",
askAgain: "اسأل مجددًا",
askAgainFor: "اسأل مجددًا عن {{label}}",
unavailable: "غير موجود على مضيف متصل",
empty: "تظهر لوحات الملفات هنا عندما تضيف إحدى الإضافات لوحة",
},
notifications: {
title: "الإشعارات",
permission: "إذن الإشعارات",
@@ -2333,6 +2359,7 @@ export const ar: TranslationResources = {
searchPlaceholder: "البحث في الاختصارات",
unavailableOnMobile: "اختصارات لوحة المفاتيح متاحة فقط على سطح المكتب",
capturePrompt: "اضغط على الاختصار...",
captureIndexPrompt: "اختم برقم من 1 إلى 9.",
unassigned: "غير معين",
actions: {
menu: "إجراءات {{name}}",
+29 -2
View File
@@ -535,6 +535,7 @@ export const en = {
},
},
setup: {
updateHost: "Update the host to view workspace setup details",
descriptor: {
label: "Setup",
completed: "Setup completed",
@@ -1942,6 +1943,24 @@ export const en = {
creatingAgent: "Creating agent",
},
file: {
filePanels: {
pickerTitleExtension: "Open .{{extension}} files with",
pickerTitleFilename: "Open {{filename}} with",
dontAskAgain: "Don't ask me again",
use: "Use",
openWith: "Open with…",
alwaysUseExtension: "Always use for .{{extension}}",
alwaysUseFilename: "Always use for {{filename}}",
option: "{{title}} — {{owner}}",
actions: "File actions",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Image",
source: "Source",
},
},
directoryMissing: "Workspace directory not found.",
loading: "Loading file...",
noPreview: "No preview available",
@@ -1959,8 +1978,6 @@ export const en = {
changedOnDisk: "Changed on disk",
vimMode: "Vim mode {{mode}}",
cursor: "Line {{line}}, column {{column}}",
preview: "Preview",
source: "Source",
deletedTitle: "File deleted on disk",
checkFailedTitle: "Couldn't check file on disk",
preservedDescription: "The open copy is preserved.",
@@ -2096,6 +2113,7 @@ export const en = {
appearance: "Appearance",
sidebar: "Sidebar",
editor: "Editor",
files: "Files",
terminal: "Terminal",
browser: "Browser",
shortcuts: "Shortcuts",
@@ -2149,6 +2167,14 @@ export const en = {
vimKeybindings: "Vim keybindings",
vimHint: "Applies to source files on web and desktop.",
},
files: {
title: "Files",
askEveryTime: "Ask every time",
askAgain: "Ask again",
askAgainFor: "Ask again for {{label}}",
unavailable: "Not on a connected host",
empty: "File panels appear here when a plugin adds one",
},
notifications: {
title: "Notifications",
permission: "Notification permission",
@@ -2456,6 +2482,7 @@ export const en = {
searchPlaceholder: "Search shortcuts",
unavailableOnMobile: "Keyboard shortcuts are only available on desktop",
capturePrompt: "Press shortcut...",
captureIndexPrompt: "End with a number from 1 to 9.",
unassigned: "Not set",
actions: {
menu: "Actions for {{name}}",
+29 -2
View File
@@ -544,6 +544,7 @@ export const es: TranslationResources = {
},
},
setup: {
updateHost: "Actualiza el host para ver los detalles de configuración del espacio de trabajo",
descriptor: {
label: "Configuración",
completed: "Configuración completada",
@@ -1965,6 +1966,24 @@ export const es: TranslationResources = {
creatingAgent: "Agente creador",
},
file: {
filePanels: {
pickerTitleExtension: "Abrir archivos .{{extension}} con",
pickerTitleFilename: "Abrir {{filename}} con",
dontAskAgain: "No volver a preguntar",
use: "Usar",
openWith: "Abrir con…",
alwaysUseExtension: "Usar siempre para .{{extension}}",
alwaysUseFilename: "Usar siempre para {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Acciones del archivo",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Imagen",
source: "Código fuente",
},
},
directoryMissing: "No se encontró el directorio de Workspace.",
loading: "Cargando archivo...",
noPreview: "No hay vista previa disponible",
@@ -1982,8 +2001,6 @@ export const es: TranslationResources = {
changedOnDisk: "Cambiado en disco",
vimMode: "Modo Vim {{mode}}",
cursor: "Línea {{line}}, columna {{column}}",
preview: "Vista previa",
source: "Código fuente",
deletedTitle: "Archivo eliminado del disco",
checkFailedTitle: "No se pudo comprobar el archivo en disco",
preservedDescription: "La copia abierta se ha conservado.",
@@ -2121,6 +2138,7 @@ export const es: TranslationResources = {
terminal: "Terminal",
browser: "Navegador",
editor: "Editor",
files: "Archivos",
shortcuts: "Atajos",
integrations: "Integraciones",
notifications: "Notificaciones",
@@ -2134,6 +2152,14 @@ export const es: TranslationResources = {
vimKeybindings: "Atajos de Vim",
vimHint: "Se aplica a archivos fuente en web y escritorio.",
},
files: {
title: "Archivos",
askEveryTime: "Preguntar siempre",
askAgain: "Volver a preguntar",
askAgainFor: "Volver a preguntar para {{label}}",
unavailable: "No está en un host conectado",
empty: "Los paneles de archivo aparecen aquí cuando un plugin añade uno",
},
notifications: {
title: "Notificaciones",
permission: "Permiso de notificaciones",
@@ -2387,6 +2413,7 @@ export const es: TranslationResources = {
searchPlaceholder: "Buscar atajos",
unavailableOnMobile: "Los atajos de teclado solo están disponibles en el escritorio",
capturePrompt: "Presione el acceso directo...",
captureIndexPrompt: "Termina con un número del 1 al 9.",
unassigned: "Sin asignar",
actions: {
menu: "Acciones para {{name}}",
+30 -2
View File
@@ -543,6 +543,8 @@ export const fr: TranslationResources = {
},
},
setup: {
updateHost:
"Mettez à jour l’hôte pour afficher les détails de configuration de l’espace de travail",
descriptor: {
label: "Configuration",
completed: "Configuration terminée",
@@ -1962,6 +1964,24 @@ export const fr: TranslationResources = {
creatingAgent: "Création de l’agent",
},
file: {
filePanels: {
pickerTitleExtension: "Ouvrir les fichiers .{{extension}} avec",
pickerTitleFilename: "Ouvrir {{filename}} avec",
dontAskAgain: "Ne plus demander",
use: "Utiliser",
openWith: "Ouvrir avec…",
alwaysUseExtension: "Toujours utiliser pour .{{extension}}",
alwaysUseFilename: "Toujours utiliser pour {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Actions du fichier",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Image",
source: "Source",
},
},
directoryMissing: "Répertoire de l’espace de travail introuvable.",
loading: "Chargement du fichier…",
noPreview: "Aucun aperçu disponible",
@@ -1979,8 +1999,6 @@ export const fr: TranslationResources = {
changedOnDisk: "Modifié sur le disque",
vimMode: "Mode Vim {{mode}}",
cursor: "Ligne {{line}}, colonne {{column}}",
preview: "Aperçu",
source: "Source",
deletedTitle: "Fichier supprimé du disque",
checkFailedTitle: "Impossible de vérifier le fichier sur le disque",
preservedDescription: "La copie ouverte est conservée.",
@@ -2120,6 +2138,7 @@ export const fr: TranslationResources = {
terminal: "Terminal",
browser: "Navigateur",
editor: "Éditeur",
files: "Fichiers",
shortcuts: "Raccourcis",
integrations: "Intégrations",
notifications: "Notifications",
@@ -2133,6 +2152,14 @@ export const fr: TranslationResources = {
vimKeybindings: "Raccourcis Vim",
vimHint: "S’applique aux fichiers source dans l’app web et l’app de bureau.",
},
files: {
title: "Fichiers",
askEveryTime: "Toujours demander",
askAgain: "Redemander",
askAgainFor: "Redemander pour {{label}}",
unavailable: "Absent des hôtes connectés",
empty: "Les panneaux de fichier apparaissent ici quand un plugin en ajoute un",
},
notifications: {
title: "Notifications",
permission: "Autorisation des notifications",
@@ -2387,6 +2414,7 @@ export const fr: TranslationResources = {
searchPlaceholder: "Rechercher des raccourcis",
unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur l’app de bureau",
capturePrompt: "Appuyez sur le raccourci…",
captureIndexPrompt: "Terminez par un chiffre de 1 à 9.",
unassigned: "Non attribué",
actions: {
menu: "Actions pour {{name}}",
+29 -2
View File
@@ -544,6 +544,7 @@ export const ja: TranslationResources = {
},
},
setup: {
updateHost: "ワークスペースのセットアップ詳細を表示するにはホストを更新してください",
descriptor: {
label: "セットアップ",
completed: "セットアップ完了",
@@ -1936,6 +1937,24 @@ export const ja: TranslationResources = {
creatingAgent: "エージェントを作成中",
},
file: {
filePanels: {
pickerTitleExtension: ".{{extension}} ファイルを開くアプリ",
pickerTitleFilename: "{{filename}} を開くアプリ",
dontAskAgain: "今後は確認しない",
use: "使用",
openWith: "このアプリで開く…",
alwaysUseExtension: ".{{extension}} に常に使用",
alwaysUseFilename: "{{filename}} に常に使用",
option: "{{title}} — {{owner}}",
actions: "ファイルの操作",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "画像",
source: "ソース",
},
},
directoryMissing: "ワークスペースディレクトリが見つかりません。",
loading: "ファイルを読み込み中...",
noPreview: "プレビューが利用できません",
@@ -1953,8 +1972,6 @@ export const ja: TranslationResources = {
changedOnDisk: "ディスク上で変更されました",
vimMode: "Vim モード {{mode}}",
cursor: "{{line}} 行、{{column}} 列",
preview: "プレビュー",
source: "ソース",
deletedTitle: "ファイルはディスク上から削除されました",
checkFailedTitle: "ディスク上のファイルを確認できませんでした",
preservedDescription: "開いているコピーは保持されています。",
@@ -2094,6 +2111,7 @@ export const ja: TranslationResources = {
terminal: "ターミナル",
browser: "ブラウザ",
editor: "エディター",
files: "ファイル",
shortcuts: "ショートカット",
integrations: "連携",
notifications: "通知",
@@ -2107,6 +2125,14 @@ export const ja: TranslationResources = {
vimKeybindings: "Vim キーバインド",
vimHint: "Web とデスクトップのソースファイルに適用されます。",
},
files: {
title: "ファイル",
askEveryTime: "毎回確認する",
askAgain: "もう一度確認する",
askAgainFor: "{{label}} をもう一度確認する",
unavailable: "接続中のホストにありません",
empty: "プラグインがファイルパネルを追加すると、ここに表示されます",
},
notifications: {
title: "通知",
permission: "通知の権限",
@@ -2354,6 +2380,7 @@ export const ja: TranslationResources = {
searchPlaceholder: "ショートカットを検索",
unavailableOnMobile: "キーボードショートカットはデスクトップでのみ利用できます",
capturePrompt: "ショートカットを押してください...",
captureIndexPrompt: "最後に1〜9の数字を入力してください。",
unassigned: "未設定",
actions: {
menu: "{{name}} のアクション",
+29 -2
View File
@@ -539,6 +539,7 @@ export const ko: TranslationResources = {
},
},
setup: {
updateHost: "작업 공간 설정 세부 정보를 보려면 호스트를 업데이트하세요",
descriptor: {
label: "설정",
completed: "설정 완료",
@@ -1927,6 +1928,24 @@ export const ko: TranslationResources = {
creatingAgent: "에이전트 생성 중",
},
file: {
filePanels: {
pickerTitleExtension: ".{{extension}} 파일 열기 방식",
pickerTitleFilename: "{{filename}} 열기 방식",
dontAskAgain: "다시 묻지 않기",
use: "사용",
openWith: "다음으로 열기…",
alwaysUseExtension: ".{{extension}}에 항상 사용",
alwaysUseFilename: "{{filename}}에 항상 사용",
option: "{{title}} — {{owner}}",
actions: "파일 작업",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "이미지",
source: "소스",
},
},
directoryMissing: "워크스페이스 디렉터리를 찾을 수 없습니다.",
loading: "파일 불러오는 중...",
noPreview: "사용 가능한 미리보기가 없습니다",
@@ -1944,8 +1963,6 @@ export const ko: TranslationResources = {
changedOnDisk: "디스크에서 변경됨",
vimMode: "Vim 모드 {{mode}}",
cursor: "{{line}} 라인, {{column}} 열",
preview: "미리보기",
source: "소스",
deletedTitle: "디스크에서 파일이 삭제되었습니다.",
checkFailedTitle: "디스크의 파일을 확인할 수 없습니다.",
preservedDescription: "열린 복사본은 보존됩니다.",
@@ -2083,6 +2100,7 @@ export const ko: TranslationResources = {
terminal: "터미널",
browser: "브라우저",
editor: "편집기",
files: "파일",
shortcuts: "단축키",
integrations: "통합",
notifications: "알림",
@@ -2096,6 +2114,14 @@ export const ko: TranslationResources = {
vimKeybindings: "Vim 키 바인딩",
vimHint: "웹 및 데스크톱의 소스 파일에 적용됩니다.",
},
files: {
title: "파일",
askEveryTime: "매번 묻기",
askAgain: "다시 묻기",
askAgainFor: "{{label}} 다시 묻기",
unavailable: "연결된 호스트에 없음",
empty: "플러그인이 파일 패널을 추가하면 여기에 표시됩니다",
},
notifications: {
title: "알림",
permission: "알림 권한",
@@ -2343,6 +2369,7 @@ export const ko: TranslationResources = {
searchPlaceholder: "검색 단축키",
unavailableOnMobile: "키보드 단축키는 데스크톱에서만 사용할 수 있습니다",
capturePrompt: "단축키를 누르세요...",
captureIndexPrompt: "마지막에 1부터 9까지의 숫자를 입력하세요.",
unassigned: "설정되지 않음",
actions: {
menu: "{{name}} 작업",
+29 -2
View File
@@ -543,6 +543,7 @@ export const ptBR: TranslationResources = {
},
},
setup: {
updateHost: "Atualize o host para ver os detalhes da configuração do espaço de trabalho",
descriptor: {
label: "Configuração",
completed: "Configuração concluída",
@@ -1950,6 +1951,24 @@ export const ptBR: TranslationResources = {
creatingAgent: "Criando agente",
},
file: {
filePanels: {
pickerTitleExtension: "Abrir arquivos .{{extension}} com",
pickerTitleFilename: "Abrir {{filename}} com",
dontAskAgain: "Não perguntar novamente",
use: "Usar",
openWith: "Abrir com…",
alwaysUseExtension: "Sempre usar para .{{extension}}",
alwaysUseFilename: "Sempre usar para {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Ações do arquivo",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Imagem",
source: "Fonte",
},
},
directoryMissing: "Diretório do workspace não encontrado.",
loading: "Carregando arquivo...",
noPreview: "Nenhuma prévia disponível",
@@ -1967,8 +1986,6 @@ export const ptBR: TranslationResources = {
changedOnDisk: "Alterado no disco",
vimMode: "Modo Vim {{mode}}",
cursor: "Linha {{line}}, coluna {{column}}",
preview: "Prévia",
source: "Fonte",
deletedTitle: "Arquivo excluído do disco",
checkFailedTitle: "Não foi possível verificar o arquivo no disco",
preservedDescription: "A cópia aberta foi preservada.",
@@ -2106,6 +2123,7 @@ export const ptBR: TranslationResources = {
terminal: "Terminal",
browser: "Navegador",
editor: "Editor",
files: "Arquivos",
shortcuts: "Atalhos",
integrations: "Integrações",
notifications: "Notificações",
@@ -2119,6 +2137,14 @@ export const ptBR: TranslationResources = {
vimKeybindings: "Atalhos do Vim",
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
},
files: {
title: "Arquivos",
askEveryTime: "Perguntar sempre",
askAgain: "Perguntar de novo",
askAgainFor: "Perguntar de novo para {{label}}",
unavailable: "Não está em um host conectado",
empty: "Os painéis de arquivo aparecem aqui quando um plugin adiciona um",
},
notifications: {
title: "Notificações",
permission: "Permissão de notificações",
@@ -2368,6 +2394,7 @@ export const ptBR: TranslationResources = {
searchPlaceholder: "Pesquisar atalhos",
unavailableOnMobile: "Atalhos de teclado estão disponíveis apenas no desktop",
capturePrompt: "Pressione o atalho...",
captureIndexPrompt: "Termine com um número de 1 a 9.",
unassigned: "Não definido",
actions: {
menu: "Ações para {{name}}",
+29 -2
View File
@@ -543,6 +543,7 @@ export const ru: TranslationResources = {
},
},
setup: {
updateHost: "Обновите хост, чтобы просмотреть сведения о настройке рабочего пространства",
descriptor: {
label: "Настройка",
completed: "Настройка завершена",
@@ -1949,6 +1950,24 @@ export const ru: TranslationResources = {
creatingAgent: "Создание агента",
},
file: {
filePanels: {
pickerTitleExtension: "Открывать файлы .{{extension}} с помощью",
pickerTitleFilename: "Открывать {{filename}} с помощью",
dontAskAgain: "Больше не спрашивать",
use: "Использовать",
openWith: "Открыть с помощью…",
alwaysUseExtension: "Всегда использовать для .{{extension}}",
alwaysUseFilename: "Всегда использовать для {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Действия с файлом",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Изображение",
source: "Исходный текст",
},
},
directoryMissing: "Каталог рабочего пространства не найден.",
loading: "Загрузка файла...",
noPreview: "Предварительный просмотр недоступен",
@@ -1966,8 +1985,6 @@ export const ru: TranslationResources = {
changedOnDisk: "Изменён на диске",
vimMode: "Режим Vim {{mode}}",
cursor: "Строка {{line}}, столбец {{column}}",
preview: "Предпросмотр",
source: "Исходный текст",
deletedTitle: "Файл удалён с диска",
checkFailedTitle: "Не удалось проверить файл на диске",
preservedDescription: "Открытая копия сохранена в редакторе.",
@@ -2106,6 +2123,7 @@ export const ru: TranslationResources = {
terminal: "Терминал",
browser: "Браузер",
editor: "Редактор",
files: "Файлы",
shortcuts: "Сочетания клавиш",
integrations: "Интеграции",
notifications: "Уведомления",
@@ -2119,6 +2137,14 @@ export const ru: TranslationResources = {
vimKeybindings: "Сочетания клавиш Vim",
vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
},
files: {
title: "Файлы",
askEveryTime: "Спрашивать каждый раз",
askAgain: "Спросить снова",
askAgainFor: "Спросить снова для {{label}}",
unavailable: "Нет на подключённых хостах",
empty: "Панели файлов появятся здесь, когда плагин добавит их",
},
notifications: {
title: "Уведомления",
permission: "Разрешение на уведомления",
@@ -2373,6 +2399,7 @@ export const ru: TranslationResources = {
searchPlaceholder: "Поиск сочетаний клавиш",
unavailableOnMobile: "Сочетания клавиш доступны только на рабочем столе.",
capturePrompt: "Нажмите сочетание клавиш...",
captureIndexPrompt: "Завершите цифрой от 1 до 9.",
unassigned: "Не задано",
actions: {
menu: "Действия для {{name}}",
+29 -2
View File
@@ -538,6 +538,7 @@ export const zhCN: TranslationResources = {
},
},
setup: {
updateHost: "更新主机以查看工作区设置详情",
descriptor: {
label: "Setup",
completed: "Setup 已完成",
@@ -1895,6 +1896,24 @@ export const zhCN: TranslationResources = {
creatingAgent: "正在创建 Agent",
},
file: {
filePanels: {
pickerTitleExtension: "打开 .{{extension}} 文件的方式",
pickerTitleFilename: "打开 {{filename}} 的方式",
dontAskAgain: "不再询问",
use: "使用",
openWith: "打开方式…",
alwaysUseExtension: "始终用于 .{{extension}}",
alwaysUseFilename: "始终用于 {{filename}}",
option: "{{title}} — {{owner}}",
actions: "文件操作",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "图片",
source: "源代码",
},
},
directoryMissing: "未找到 workspace 目录。",
loading: "正在加载文件...",
noPreview: "没有可用预览",
@@ -1912,8 +1931,6 @@ export const zhCN: TranslationResources = {
changedOnDisk: "磁盘上的文件已更改",
vimMode: "Vim 模式 {{mode}}",
cursor: "第 {{line}} 行,第 {{column}} 列",
preview: "预览",
source: "源代码",
deletedTitle: "文件已从磁盘删除",
checkFailedTitle: "无法检查磁盘上的文件",
preservedDescription: "打开的副本已保留。",
@@ -2051,6 +2068,7 @@ export const zhCN: TranslationResources = {
terminal: "终端",
browser: "浏览器",
editor: "编辑器",
files: "文件",
shortcuts: "快捷键",
integrations: "集成",
notifications: "通知",
@@ -2064,6 +2082,14 @@ export const zhCN: TranslationResources = {
vimKeybindings: "Vim 键位",
vimHint: "适用于网页和桌面端的源文件。",
},
files: {
title: "文件",
askEveryTime: "每次询问",
askAgain: "重新询问",
askAgainFor: "重新询问 {{label}}",
unavailable: "不在已连接的主机上",
empty: "插件添加文件面板后会显示在这里",
},
notifications: {
title: "通知",
permission: "通知权限",
@@ -2305,6 +2331,7 @@ export const zhCN: TranslationResources = {
searchPlaceholder: "搜索快捷键",
unavailableOnMobile: "键盘快捷键仅在桌面端可用",
capturePrompt: "按下快捷键...",
captureIndexPrompt: "请以 1 到 9 的数字结尾。",
unassigned: "未设置",
actions: {
menu: "{{name}} 的操作",
@@ -4,7 +4,9 @@ import { keyboardEventToComboString } from "./shortcut-string";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
DEFAULT_BINDINGS,
normalizeCapturedShortcutCombo,
isShortcutCaptureComplete,
getBindingIdForAction,
getDefaultKeysForAction,
getWorkspaceIndexJumpModifierKey,
@@ -1390,3 +1392,22 @@ describe("number-group shortcut capture", () => {
expect(normalizeCapturedShortcutCombo(tabBindingId, "Ctrl+9")).toBe("Ctrl+Digit");
});
});
describe("shortcut capture completion", () => {
const indexBindings = DEFAULT_BINDINGS.filter((binding) => binding.payload?.type === "index");
it.each(indexBindings)("requires a final 1–9 number for $id", ({ id }) => {
for (const combos of [[], ["Ctrl+K"], ["Ctrl+0"], ["Ctrl+Digit", "Ctrl+K"]]) {
expect(isShortcutCaptureComplete(id, combos)).toBe(false);
}
for (let index = 1; index <= 9; index++) {
const combo = normalizeCapturedShortcutCombo(id, `Ctrl+${index}`);
expect(isShortcutCaptureComplete(id, [combo])).toBe(true);
expect(isShortcutCaptureComplete(id, ["Ctrl+K", combo])).toBe(true);
}
});
it("keeps ordinary shortcuts available without a number", () => {
expect(isShortcutCaptureComplete("show-shortcuts", [])).toBe(false);
expect(isShortcutCaptureComplete("show-shortcuts", ["Ctrl+K"])).toBe(true);
expect(isShortcutCaptureComplete("show-shortcuts", ["Ctrl+0"])).toBe(true);
});
});
@@ -1217,6 +1217,14 @@ export function normalizeCapturedShortcutCombo(bindingId: string, combo: string)
return combo.replace(/(^|\+)[1-9]$/, "$1Digit");
}
/** Index navigation needs a final number; earlier combos may be any chord prefix. */
export function isShortcutCaptureComplete(bindingId: string, combos: readonly string[]): boolean {
const lastCombo = combos.at(-1);
if (!lastCombo) return false;
const binding = DEFAULT_BINDINGS.find((candidate) => candidate.id === bindingId);
return binding?.payload?.type !== "index" || /(^|\+)(Digit|[1-9])$/.test(lastCombo);
}
export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShortcutBinding[] {
return DEFAULT_BINDINGS.map(function (binding) {
const override = overrides[binding.id];
+60 -47
View File
@@ -1,7 +1,7 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
@@ -17,6 +17,9 @@ import {
type WorkspaceSetupSnapshot,
} from "@/stores/workspace-setup-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { useHostFeature } from "@/runtime/host-features";
function useSetupPanelDescriptor(
@@ -24,13 +27,11 @@ function useSetupPanelDescriptor(
context: { serverId: string; workspaceId: string },
): PanelDescriptor {
const { t } = useTranslation();
const key = buildWorkspaceTabPersistenceKey({
serverId: context.serverId,
workspaceId: target.workspaceId,
});
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
const fields = useWorkspaceFields(context.serverId, target.workspaceId, (workspace) => ({
status: workspace.setupStatus ?? null,
}));
if (snapshot?.status === "completed") {
if (fields?.status === "completed") {
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.completed"),
@@ -41,7 +42,7 @@ function useSetupPanelDescriptor(
};
}
if (snapshot?.status === "failed") {
if (fields?.status === "failed") {
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.failed"),
@@ -52,7 +53,7 @@ function useSetupPanelDescriptor(
};
}
if (snapshot?.status === "blocked") {
if (fields?.status === "blocked") {
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.blocked"),
@@ -69,7 +70,7 @@ function useSetupPanelDescriptor(
tooltip: t("workspace.setup.descriptor.workspace"),
titleState: "ready",
icon: SquareTerminal,
statusBucket: snapshot?.status === "running" ? "running" : null,
statusBucket: fields?.status === "running" ? "running" : null,
};
}
@@ -216,33 +217,34 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
return { hasError, hasLog, isExpandable, isAutoExpanded, showDetail, processedLog };
}
function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string) {
function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string, enabled: boolean) {
const client = useHostRuntimeClient(serverId);
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
const requestedRef = useRef(false);
const applySnapshot = useWorkspaceSetupStore((state) => state.applySnapshot);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (snapshot || requestedRef.current || !client) return;
requestedRef.current = true;
client
.fetchWorkspaceSetupStatus(workspaceId)
.then((response) => {
if (response.snapshot) {
upsertProgress({
serverId,
payload: { workspaceId: response.workspaceId, ...response.snapshot },
});
}
return;
})
.catch(() => {
// Server may not support this yet — ignore.
});
}, [client, snapshot, serverId, upsertProgress, workspaceId]);
return snapshot;
if (!client || !enabled) return;
setError(null);
const subscription = client.observeWorkspaceSetup(workspaceId);
const detach = subscription.subscribe({
snapshot: (response) => {
applySnapshot({ serverId, workspaceId, snapshot: response.snapshot });
},
update: (message) => {
if (message.type === "workspace_setup_progress")
applySnapshot({ serverId, workspaceId, snapshot: message.payload });
},
error: (failure) => setError(failure instanceof Error ? failure.message : String(failure)),
});
return () => {
detach();
void subscription
.release()
.catch((failure) => console.warn("Failed to release setup observation", failure));
};
}, [client, enabled, serverId, applySnapshot, workspaceId]);
return { snapshot, error };
}
function useExpandedSetupCommands() {
@@ -273,7 +275,14 @@ function SetupPanel() {
const { serverId, target } = usePaneContext();
invariant(target.kind === "setup", "SetupPanel requires setup target");
const snapshot = useWorkspaceSetupSnapshot(serverId, target.workspaceId);
const isActive = useRetainedPanelActive();
const isVisible = useAppActivelyVisible();
const supportsObservation = useHostFeature(serverId, "workspaceSetupObservation");
const { snapshot, error: observationError } = useWorkspaceSetupSnapshot(
serverId,
target.workspaceId,
isActive && isVisible && supportsObservation,
);
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
const log = snapshot?.detail.log ?? "";
@@ -306,19 +315,23 @@ function SetupPanel() {
<Text style={styles.hiddenStatus} testID="workspace-setup-status">
{statusLabel}
</Text>
<SetupPanelBody
serverId={serverId}
workspaceId={target.workspaceId}
snapshot={snapshot}
commands={commands}
log={log}
isWaiting={isWaiting}
hasNoSetupCommands={hasNoSetupCommands}
autoExpandIndex={autoExpandIndex}
expandedIndices={expandedIndices}
manuallyCollapsed={manuallyCollapsed}
onToggle={toggleExpanded}
/>
{!supportsObservation && <Alert variant="warning" title={t("workspace.setup.updateHost")} />}
{observationError && <Alert variant="error" title={observationError} />}
{supportsObservation && !observationError && (
<SetupPanelBody
serverId={serverId}
workspaceId={target.workspaceId}
snapshot={snapshot}
commands={commands}
log={log}
isWaiting={isWaiting}
hasNoSetupCommands={hasNoSetupCommands}
autoExpandIndex={autoExpandIndex}
expandedIndices={expandedIndices}
manuallyCollapsed={manuallyCollapsed}
onToggle={toggleExpanded}
/>
)}
</ScrollView>
);
}

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