mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 09:12:44 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c581c2486 | ||
|
|
4e9dbc7f54 | ||
|
|
c33c1d0855 | ||
|
|
770648327f | ||
|
|
2a2362f5cf | ||
|
|
6e57bb1e4f | ||
|
|
6db8bd124f | ||
|
|
eb1fe10375 | ||
|
|
db8c22e135 | ||
|
|
0c38afa76f | ||
|
|
7b813f7f58 | ||
|
|
d014cfdb2f |
@@ -152,6 +152,14 @@ jobs:
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Install ripgrep (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
|
||||
- name: Install ripgrep (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: choco install ripgrep --yes --no-progress
|
||||
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
|
||||
+61
-68
@@ -1,98 +1,91 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.0 - 2026-09-10
|
||||
## 0.8.0-beta.1 - 2026-09-08
|
||||
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
This release considerably extends what plugins can do: complete providers, custom components in the chat, slash commands, settings screens, and agent lifecycle hooks.
|
||||
|
||||
**Before upgrading:** desktop now requires macOS 13 or newer. Plugins written for 0.7 need the [0.8 migration guide](https://paseo.sh/docs/plugins/v0.8/migration), including separate client/server entries and the revised composer pill API.
|
||||
The plugin API has a breaking change. Plugins now use separate client and server entries and runtime-owned SDK entry points, so plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) before they load on 0.8.
|
||||
|
||||
### Added
|
||||
|
||||
- Added answer forms for Codex questions asked while the agent continues working ([#4587](https://github.com/getpaseo/paseo/pull/4587))
|
||||
- Added Mark as unread to finished workspaces in the sidebar menu ([#3603](https://github.com/getpaseo/paseo/pull/3603) by [@edihasaj](https://github.com/edihasaj))
|
||||
- Added an in-app What's new sheet in the help menu, Settings → About, and desktop update callout ([#4576](https://github.com/getpaseo/paseo/pull/4576))
|
||||
- Added Import session to the sidebar footer, History, and Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added search, pagination, and workspace filtering to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added sidebar item reordering and visibility controls in Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
|
||||
- Added provider notifications from Pi extensions and OpenCode to the timeline ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
|
||||
- Added a fullscreen Mermaid viewer on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Added Copy, Copy line, and Select all to the web diff context menu ([#4229](https://github.com/getpaseo/paseo/pull/4229))
|
||||
- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo))
|
||||
- Added per-provider controls for disabling all or selected Paseo tools ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
|
||||
- Added explicit approval before running setup, automatic terminals, or scripts in fork pull request workspaces ([#4215](https://github.com/getpaseo/paseo/pull/4215))
|
||||
- Added Hub follow-ups to existing agents, including archived workspace recovery ([#4354](https://github.com/getpaseo/paseo/pull/4354))
|
||||
- Added provider catalog discovery to Hub executions ([#4242](https://github.com/getpaseo/paseo/pull/4242))
|
||||
It also fixes regressions across desktop updates, mobile, Import session, and agent lifecycle.
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added custom providers with their own icons, settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
|
||||
- Added provider session recovery after plugin reload ([#4629](https://github.com/getpaseo/paseo/pull/4629) by [@mcowger](https://github.com/mcowger))
|
||||
- Added custom timeline components and persisted timeline rows ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added client slash commands that run without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added plugin settings screens under Settings → Plugins ([#4357](https://github.com/getpaseo/paseo/pull/4357))
|
||||
- Added lifecycle hooks for agent creation, turns, permissions, and archive events ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added transforms for new agents' provider, model, environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added header buttons and composer pills with actions, menus, custom popovers, and live updates ([#4577](https://github.com/getpaseo/paseo/pull/4577))
|
||||
- Added custom modal bodies with scrolling, keyboard-aware inputs, and clipboard actions ([#4392](https://github.com/getpaseo/paseo/pull/4392))
|
||||
- Added terminal creation, input, capture, and management to the SDK ([#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Added live project subscriptions through `paseo.projects.subscribe()` ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
|
||||
- Added permission responses through `agent.respondToPermission()` ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
|
||||
- Added provider usage queries through `providers.listUsage()` ([#4062](https://github.com/getpaseo/paseo/pull/4062) by [@ivanbrykov](https://github.com/ivanbrykov))
|
||||
- Added supported Paseo version requirements to plugin manifests ([#4430](https://github.com/getpaseo/paseo/pull/4430))
|
||||
- Changed plugins to separate client and server entries with runtime-specific SDK imports ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
|
||||
- Changed composer pill registration to use `button`, `registration.update()`, and `registration.remove()` ([#4577](https://github.com/getpaseo/paseo/pull/4577))
|
||||
- Changed `paseo plugin ls` to show runtime state, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Fixed workspace plugin panels missing from the Explorer menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
|
||||
- Added provider contributions: a plugin registers a complete provider, directly or through the bundled ACP shim, with its own icon, session settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
|
||||
- Added live timeline items: a plugin renders custom components in the chat by transforming timeline items at render time ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added durable timeline rows: a daemon handler appends its own persisted rows to an agent's timeline ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added slash commands: a plugin registers commands in the composer that run on the client without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added settings screens: a plugin contributes screens under Settings → Plugins from shared settings components, with host-scoped persistence, validation, and Command Center navigation ([#4357](https://github.com/getpaseo/paseo/pull/4357))
|
||||
- Added lifecycle hooks: server callbacks observe agent creation, turns, permissions, and archive events, and can answer permissions or send follow-ups ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added agent creation transforms: before hooks rewrite a new agent's provider, model, launch environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added modal bodies: a plugin owns the whole sheet body with custom padding, ScrollView or FlatList scrolling, horizontal tabs, keyboard-aware inputs, and a clipboard copy action ([#4392](https://github.com/getpaseo/paseo/pull/4392), [#4431](https://github.com/getpaseo/paseo/pull/4431))
|
||||
- Added terminals to the SDK: create, list, write, send keys, capture, and kill terminals by workspace ID ([#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Added `paseo.projects.subscribe()` for live project updates ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
|
||||
- Added `agent.respondToPermission()` to answer pending permissions from a plugin ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
|
||||
- Added version requirements: `requirements.paseo` in `paseo-plugin.json` declares the supported Paseo versions, incompatible installs, updates, and reloads are rejected before any code runs, and prerelease Paseo versions satisfy any range their stable core satisfies ([#4430](https://github.com/getpaseo/paseo/pull/4430), [#4452](https://github.com/getpaseo/paseo/pull/4452))
|
||||
- Changed plugins to separate `index.client.ts` and `index.server.ts` entries with `client/`, `server/`, and `shared/` directories; plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) ([#4206](https://github.com/getpaseo/paseo/pull/4206))
|
||||
- Changed the plugin SDK into runtime-owned entry points: `@getpaseo/plugin` for shared code, `@getpaseo/plugin/client` for the app, and `@getpaseo/plugin/server` for the daemon, with crossed imports rejected at compile time ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
|
||||
- Changed legacy plugin load failures to say the plugin was built for an older Paseo and link to the migration guide ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Changed `paseo plugin ls` to report runtime state, source, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Fixed workspace plugin panels missing from the Explorer tab-rail menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
|
||||
- Added versioned plugin documentation for v0.7 and v0.8 with a migration guide ([#4245](https://github.com/getpaseo/paseo/pull/4245), [#4452](https://github.com/getpaseo/paseo/pull/4452))
|
||||
|
||||
### Added
|
||||
|
||||
- Added Import session to the sidebar footer, the History and sidebar empty states, and the Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added search, Load more, and a This workspace / Show all scope to the Import session sheet ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added customizable top-level sidebar items: reorder and hide New workspace, History, Search, Schedules, and plugin items from Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
|
||||
- Added provider notifications from Pi extensions and OpenCode as timeline items with info, warning, and error levels ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
|
||||
- Added a fullscreen viewer for Mermaid diagrams on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Added Copy, Copy line, and Select all to the web diff context menu, with Cmd/Ctrl+A and Escape ([#4229](https://github.com/getpaseo/paseo/pull/4229))
|
||||
- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo))
|
||||
- Added per-provider Paseo tool policy in daemon config to disable all or selected Paseo tools for a provider ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
|
||||
- Added explicit setup approval for fork pull request workspaces: setup, automatic terminals, and scripts wait for Run setup or `paseo workspace setup <id>` ([#4215](https://github.com/getpaseo/paseo/pull/4215))
|
||||
- Added Hub follow-ups to existing agents, archived workspace recovery, and provider catalog discovery through the daemon API ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4242](https://github.com/getpaseo/paseo/pull/4242))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Changed `--host` to a global CLI option: `paseo --host <target> <command>` ([#4238](https://github.com/getpaseo/paseo/pull/4238))
|
||||
- Changed the sidebar footer Home button to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Changed `paseo hub init` and default `paseo hub deploy` to use organization triggers in `.paseo/triggers/` ([#4517](https://github.com/getpaseo/paseo/pull/4517))
|
||||
- Changed Codex Fast availability to the supported model list, including GPT-6 Astra ([#4640](https://github.com/getpaseo/paseo/pull/4640))
|
||||
- Changed CLI installs to resolve patched React 19.1.x releases ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
|
||||
- Changed the sidebar footer Home button to Import session; the Home route and tile remain ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
|
||||
### Improved
|
||||
|
||||
- Reconnected every host immediately when returning to the app ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Smoothed mobile keyboard transitions by moving the timeline and composer together ([#4275](https://github.com/getpaseo/paseo/pull/4275))
|
||||
- Reduced stalls when opening large diffs ([#4574](https://github.com/getpaseo/paseo/pull/4574))
|
||||
- Reloaded only providers whose configuration changed ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Removed unsolicited timeline and catalog traffic from idle SDK connections ([#4470](https://github.com/getpaseo/paseo/pull/4470))
|
||||
- Added macOS updater diagnostics to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Reconnected every host immediately on app foreground instead of waiting for reconnect backoff ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Smoothed mobile keyboard transitions by moving the timeline and composer together instead of relaying out every frame ([#4275](https://github.com/getpaseo/paseo/pull/4275))
|
||||
- Reloaded only the providers whose configuration changed instead of restarting every provider and resetting every catalog ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Shortened agent tab tooltips to one title line with the agent ID and last activity ([#4214](https://github.com/getpaseo/paseo/pull/4214))
|
||||
- Added macOS updater and ShipIt state to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
|
||||
- Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Fixed the desktop updater reporting up to date before release manifests were available ([#4201](https://github.com/getpaseo/paseo/pull/4201))
|
||||
- Fixed the desktop updater reporting up to date while a new release had no updater manifests yet ([#4201](https://github.com/getpaseo/paseo/pull/4201))
|
||||
- Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207))
|
||||
- Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389))
|
||||
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
|
||||
- Fixed cached conversations and workspace lists failing to appear when live updates arrived during loading ([#4436](https://github.com/getpaseo/paseo/pull/4436))
|
||||
- Fixed cached conversations and the workspace list not painting when live updates arrived during cache reads ([#4436](https://github.com/getpaseo/paseo/pull/4436))
|
||||
- Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Fixed Codex reloads and follow-ups after workspace archive failing with an active-writer error ([#4353](https://github.com/getpaseo/paseo/pull/4353))
|
||||
- Fixed repeated Claude compaction updates leaving extra Compacting rows spinning ([#4391](https://github.com/getpaseo/paseo/pull/4391) by [@tomgrin10](https://github.com/tomgrin10))
|
||||
- Fixed the New workspace form losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
|
||||
- Fixed a crash with `Select a model` when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Fixed Codex model rows staying on Loading forever in the New workspace model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed reloading a Codex agent failing with `already has an active writer` ([#4353](https://github.com/getpaseo/paseo/pull/4353))
|
||||
- Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl))
|
||||
- Fixed delayed workspace creation navigating away after you switched elsewhere ([#2986](https://github.com/getpaseo/paseo/pull/2986) by [@cleiter](https://github.com/cleiter))
|
||||
- Fixed New workspace losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
|
||||
- Fixed a crash when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Fixed Codex model rows staying on Loading in the model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed nested subagents appearing under the root agent instead of their actual parent ([#4321](https://github.com/getpaseo/paseo/pull/4321))
|
||||
- Fixed repeated tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
|
||||
- Fixed Import session loading forever when one provider hung ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed nested provider subagents shown as direct children of the root agent and their notifications appearing in the root timeline ([#4321](https://github.com/getpaseo/paseo/pull/4321))
|
||||
- Fixed repeated provider tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
|
||||
- Fixed the Import session spinner never stopping when one provider hung; the provider now shows an inline error with Retry ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed importing a session creating a duplicate workspace for an already-open directory ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed importing a session into a directory with an active workspace creating a duplicate workspace ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335))
|
||||
- Fixed restored terminal titles briefly showing Terminal before loading ([#4553](https://github.com/getpaseo/paseo/pull/4553))
|
||||
- Fixed diff file headers lagging behind while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
|
||||
- Fixed Changes and its diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
|
||||
- Fixed Mermaid diagrams shrinking during streaming or becoming corrupted on reload ([#4210](https://github.com/getpaseo/paseo/pull/4210))
|
||||
- Fixed diff file headers lagging behind the document while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
|
||||
- Fixed the Changes tree and its working diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
|
||||
- Fixed Mermaid diagrams shrinking on every streaming re-render and the inline diagram box collapsing ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed Mermaid source corrupted on reload when cached history overlapped the live message ([#4210](https://github.com/getpaseo/paseo/pull/4210))
|
||||
- Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228))
|
||||
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354))
|
||||
- Fixed daemon Git commands allowing a repository's `core.fsmonitor` to execute its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
|
||||
- Fixed terminal activity reporting for OpenCode 2 sessions ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
|
||||
- Fixed the website offering downloads for the wrong platform before loading ([#4222](https://github.com/getpaseo/paseo/pull/4222))
|
||||
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Fixed daemon Git commands honoring a repository's `core.fsmonitor`, which let a checkout run its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
|
||||
- Fixed terminal activity reporting for OpenCode 2 sessions started from a terminal ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
|
||||
- Changed the plugin SDK's React peer range to `~19.1.0` so CLI installs resolve a patched 19.1.x instead of the version dependency scanners flag ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
|
||||
- Fixed the paseo.sh homepage offering the Mac download to every visitor before hydration and desktop builds to phones ([#4222](https://github.com/getpaseo/paseo/pull/4222))
|
||||
|
||||
## 0.7.2 - 2026-09-02
|
||||
|
||||
|
||||
@@ -106,15 +106,10 @@ Repo dev commands use checkout-local state by default. In this checkout, `PASEO_
|
||||
|
||||
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
## Release branches
|
||||
|
||||
When the user says "this goes to next", create or
|
||||
retarget the PR to `next` and preserve that destination through delivery. Follow
|
||||
[release branch discipline](docs/release.md#release-branch-discipline) for creating
|
||||
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries.
|
||||
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
|
||||
@@ -50,11 +50,13 @@ RUN set -eux; \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
ripgrep \
|
||||
gosu \
|
||||
lbzip2 \
|
||||
openssh-client \
|
||||
procps \
|
||||
tini; \
|
||||
rg --version; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||
|
||||
+1
-31
@@ -47,36 +47,6 @@ commit the prepared inputs locally and run the release command. Its branch and
|
||||
tag push is the one remote release batch and starts CI for the complete release
|
||||
commit.
|
||||
|
||||
## Release branch discipline
|
||||
|
||||
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
|
||||
release. This applies to both beta and stable releases.
|
||||
|
||||
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
|
||||
- "This goes to next" means create the PR against `next` or retarget an existing
|
||||
PR, and keep that destination through delivery.
|
||||
- Keep `next` current by merging `origin/main` into it as release fixes land.
|
||||
Avoid rebasing this shared branch because agents and open PRs depend on its history.
|
||||
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
|
||||
Pass CI and merge without squashing away the individual PR commits needed for
|
||||
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
|
||||
`next`. Create it fresh when needed again.
|
||||
|
||||
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
|
||||
and GitHub permits only squash merges. Enable checks and required-check protection
|
||||
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
|
||||
base changes (`edited` events) so retargeting runs checks against the new base;
|
||||
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
|
||||
|
||||
### Hotfix from a release tag
|
||||
|
||||
If `main` contains changes you do not want to release, branch from the affected
|
||||
release tag and cherry-pick only the required fixes. Run CI on that branch, then
|
||||
use the normal release flow with it as the explicit source, choosing a new patch
|
||||
or beta version. Ensure the fixes and changelog also reach `main` and any active
|
||||
`next`, preserving newer development and version changes there. This is a
|
||||
short-lived hotfix branch, not another maintained release track.
|
||||
|
||||
## ACP catalog updates
|
||||
|
||||
ACP catalog work enters a release through an explicit user request:
|
||||
@@ -94,7 +64,7 @@ release push as the changelog and version commit.
|
||||
|
||||
There are two supported release paths:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
|
||||
|
||||
Added
|
||||
- Added answer forms for Codex questions asked while the agent continues working
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
|
||||
|
||||
Added
|
||||
- Added answer forms for Codex questions asked while the agent continues working
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
|
||||
|
||||
Added
|
||||
- Added answer forms for Codex questions asked while the agent continues working
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
|
||||
|
||||
Added
|
||||
- Added answer forms for Codex questions asked while the agent continues working
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-j8U5S71SJoC+5yh7IjkadrRZaHOYeMsi1z8B6K7j4R4=
|
||||
sha256-8P7kk9B2A81e13IIyyfo8fIoNGlrutfV/WGKAFHscUc=
|
||||
|
||||
Generated
+27
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36196,7 +36196,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37261,12 +37261,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.8.0",
|
||||
"@getpaseo/protocol": "0.8.0",
|
||||
"@getpaseo/server": "0.8.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/server": "0.8.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37513,10 +37513,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.8.0",
|
||||
"@getpaseo/relay": "0.8.0",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37527,7 +37527,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37538,7 +37538,6 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "44.2.0",
|
||||
@@ -37771,7 +37770,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38667,7 +38666,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38696,14 +38695,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.8.0",
|
||||
"@getpaseo/protocol": "0.8.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38713,8 +38712,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.8.0",
|
||||
"@getpaseo/protocol": "0.8.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38736,7 +38735,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38763,7 +38762,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38981,16 +38980,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.8.0",
|
||||
"@getpaseo/highlight": "0.8.0",
|
||||
"@getpaseo/plugin": "0.8.0",
|
||||
"@getpaseo/protocol": "0.8.0",
|
||||
"@getpaseo/relay": "0.8.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/highlight": "0.8.0-beta.1",
|
||||
"@getpaseo/plugin": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39868,7 +39867,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -102,8 +102,8 @@ test("dropping the files scope leaves the search row the same height", async ({
|
||||
const header = panel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
await page.getByTestId("command-center-scope").click();
|
||||
await expect(page.getByTestId("command-center-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation";
|
||||
|
||||
test.describe("Delayed workspace creation", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
for (const launch of ["chat", "terminal", "empty"] as const) {
|
||||
test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "leave");
|
||||
});
|
||||
test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree");
|
||||
});
|
||||
test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "stay");
|
||||
});
|
||||
}
|
||||
|
||||
test("preserves a newer draft opened while creation is pending", async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "chat", "new-draft");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
|
||||
function source(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
function query(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Find in pane", exact: true });
|
||||
}
|
||||
function status(page: Page) {
|
||||
return page.getByRole("status", { name: "Find matches" });
|
||||
}
|
||||
async function openSource(page: Page, filename: string) {
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, filename);
|
||||
await expect(source(page)).toBeVisible();
|
||||
}
|
||||
async function findInSource(page: Page, text: string) {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill(text);
|
||||
}
|
||||
async function expectQuerySelected(page: Page, text: string) {
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue(text);
|
||||
await expect
|
||||
.poll(() =>
|
||||
query(page).evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd]),
|
||||
)
|
||||
.toEqual([0, text.length]);
|
||||
}
|
||||
async function closeFind(page: Page) {
|
||||
await query(page).press("Escape");
|
||||
await expect(source(page)).toBeFocused();
|
||||
}
|
||||
async function expectActiveMatchUncovered(page: Page) {
|
||||
const match = page.locator(".cm-searchMatch-selected").filter({ visible: true }).first();
|
||||
await expect(match).toBeVisible();
|
||||
const widget = page.getByLabel("Find", { exact: true }).filter({ visible: true });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const m = await match.boundingBox();
|
||||
const w = await widget.boundingBox();
|
||||
if (!m || !w) return "missing";
|
||||
const covered =
|
||||
m.x < w.x + w.width && m.x + m.width > w.x && m.y < w.y + w.height && m.y + m.height > w.y;
|
||||
return covered ? "covered by Find" : "visible";
|
||||
})
|
||||
.toBe("visible");
|
||||
}
|
||||
|
||||
async function findBoxes(page: Page) {
|
||||
const pane = (await page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.boundingBox())!;
|
||||
const widget = (await page
|
||||
.getByLabel("Find", { exact: true })
|
||||
.filter({ visible: true })
|
||||
.boundingBox())!;
|
||||
return { pane, widget };
|
||||
}
|
||||
|
||||
async function expectFindInsidePane(page: Page) {
|
||||
const { pane, widget } = await findBoxes(page);
|
||||
expect(widget.x).toBeGreaterThanOrEqual(pane.x);
|
||||
expect(widget.x + widget.width).toBeLessThanOrEqual(pane.x + pane.width);
|
||||
expect(widget.y).toBeGreaterThanOrEqual(pane.y);
|
||||
expect(widget.y + widget.height).toBeLessThanOrEqual(pane.y + pane.height + 1);
|
||||
}
|
||||
|
||||
async function expectFindAtTopRight(page: Page) {
|
||||
await expectFindInsidePane(page);
|
||||
const { pane, widget } = await findBoxes(page);
|
||||
expect(widget.y).toBeLessThan(pane.y + 70);
|
||||
}
|
||||
|
||||
test("finds literal text at the top of editable source and returns focus at the inspected match", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-" });
|
||||
const file = path.join(workspace.repoPath, "source.txt");
|
||||
await writeFile(file, "first a.b\nsecond axb\nthird a.b\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "source.txt");
|
||||
await findInSource(page, "a.b");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await query(page).fill("A.B");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await query(page).fill("a.b");
|
||||
await expectFindAtTopRight(page);
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await query(page).press("Shift+Enter");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await page.screenshot({ path: testInfo.outputPath("editable-find.png") });
|
||||
await closeFind(page);
|
||||
await expect(query(page)).toBeHidden();
|
||||
await expect(page.getByLabel("Line 1, column 10")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
|
||||
|
||||
await test.step("reopen with the shortcut, preserve replace and Undo/save", async () => {
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("literal");
|
||||
await page.getByRole("button", { name: "Replace", exact: true }).click();
|
||||
await expect(source(page)).toContainText("first literal");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await page.screenshot({ path: testInfo.outputPath("replace.png") });
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+z");
|
||||
await expect(source(page)).toContainText("first a.b");
|
||||
await source(page).press("ControlOrMeta+s");
|
||||
await expect.poll(() => readFile(file, "utf8")).toBe("first a.b\nsecond axb\nthird a.b\n");
|
||||
await findInSource(page, "a.b");
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("all");
|
||||
await page.getByRole("button", { name: "Replace all", exact: true }).click();
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+s");
|
||||
await expect.poll(() => readFile(file, "utf8")).toBe("first all\nsecond axb\nthird all\n");
|
||||
});
|
||||
});
|
||||
|
||||
test("searches the unsaved buffer while disconnected", async ({ page, withWorkspace }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-draft-" });
|
||||
const file = path.join(workspace.repoPath, "draft.txt");
|
||||
await writeFile(file, "saved on disk\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "draft.txt");
|
||||
await gate.drop();
|
||||
await source(page).fill("unsaved needle\nsecond needle\n");
|
||||
await findInSource(page, "needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
expect(await readFile(file, "utf8")).toBe("saved on disk\n");
|
||||
gate.restore();
|
||||
});
|
||||
|
||||
test("searches read-only source beyond the viewport without replace controls", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-readonly-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "large.txt"),
|
||||
"needle first\n" + "plain source line\n".repeat(70_000) + "needle last\n",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "large.txt");
|
||||
await findInSource(page, "needle");
|
||||
await expect(source(page)).toHaveAttribute("contenteditable", "false");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expect(source(page)).toContainText("needle last");
|
||||
await page.screenshot({ path: testInfo.outputPath("readonly-find.png") });
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("plain");
|
||||
await expect(status(page)).toHaveText("1 of 10000+");
|
||||
});
|
||||
|
||||
test("targets the focused source in split panes and refocuses an open query", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-split-" });
|
||||
await writeFile(path.join(workspace.repoPath, "left.txt"), "left needle\n");
|
||||
await writeFile(path.join(workspace.repoPath, "right.txt"), "right needle\nright needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "left.txt");
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await openFileFromExplorer(page, "right.txt");
|
||||
const left = source(page).filter({ hasText: "left needle" });
|
||||
const right = source(page).filter({ hasText: "right needle" });
|
||||
await expect(left).toBeVisible();
|
||||
await expect(right).toBeVisible();
|
||||
await left.click();
|
||||
await left.press("ControlOrMeta+f");
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await query(page).press("Escape");
|
||||
await right.click();
|
||||
await right.press("ControlOrMeta+f");
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await right.click();
|
||||
await right.press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
|
||||
});
|
||||
|
||||
test.describe("narrow touch browser", () => {
|
||||
test.use({ hasTouch: true });
|
||||
test("keeps Find controls inside a narrow source pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-touch-" });
|
||||
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "touch.txt");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find-closed.png") });
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectFindInsidePane(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
async function selectFirstTwoLines(page: Page) {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+Home");
|
||||
await source(page).press("Shift+ArrowDown");
|
||||
await source(page).press("Shift+End");
|
||||
}
|
||||
|
||||
async function selectLastWord(page: Page) {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+End");
|
||||
await source(page).press("ArrowUp");
|
||||
await source(page).press("Home");
|
||||
await source(page).press("Shift+End");
|
||||
}
|
||||
|
||||
test("declines multiline selection seeds and keeps replacement aligned with the visible query", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-single-line-" });
|
||||
const file = path.join(workspace.repoPath, "selection.txt");
|
||||
await writeFile(file, "alpha\nbeta\nalphabeta\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "selection.txt");
|
||||
|
||||
await selectFirstTwoLines(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
|
||||
await query(page).press("Enter");
|
||||
await expect(query(page)).toHaveValue("");
|
||||
await closeFind(page);
|
||||
|
||||
await selectFirstTwoLines(page);
|
||||
await source(page).press("ControlOrMeta+g");
|
||||
await expect(query(page)).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
|
||||
await closeFind(page);
|
||||
|
||||
await selectLastWord(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toHaveValue("alphabeta");
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await closeFind(page);
|
||||
await expect(page.getByLabel("Line 3, column 10")).toBeVisible();
|
||||
|
||||
await selectFirstTwoLines(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toHaveValue("alphabeta");
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("replaced");
|
||||
await page.getByRole("button", { name: "Replace", exact: true }).click();
|
||||
await expect(source(page)).toContainText("alpha");
|
||||
await expect(source(page)).toContainText("beta");
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
await page.screenshot({ path: testInfo.outputPath("single-line-query-replacement.png") });
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+s");
|
||||
await expect.poll(() => readFile(file, "utf8")).toBe("alpha\nbeta\nreplaced\n");
|
||||
});
|
||||
|
||||
for (const startingField of ["Find in pane", "Replace with"] as const) {
|
||||
test(`repeated Find from ${startingField} selects the query for new typing`, async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-repeat-input-" });
|
||||
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "source.txt");
|
||||
await findInSource(page, "needle");
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
const replacement = page.getByRole("textbox", { name: "Replace with" });
|
||||
await replacement.fill("replacement");
|
||||
|
||||
await test.step("repeat Find from the input and type a fresh query", async () => {
|
||||
const startingInput = page.getByRole("textbox", { name: startingField, exact: true });
|
||||
await startingInput.press("End");
|
||||
await startingInput.press("ControlOrMeta+f");
|
||||
await expectQuerySelected(page, "needle");
|
||||
await page.keyboard.type("first");
|
||||
await expect(query(page)).toHaveValue("first");
|
||||
await expect(replacement).toHaveValue("replacement");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
});
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath("repeat-find-input.png") });
|
||||
await closeFind(page);
|
||||
await expect(page.getByLabel("Line 1, column 13")).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("the active match stays visible under the floating widget", () => {
|
||||
test.describe("narrow touch browser", () => {
|
||||
test.use({ hasTouch: true });
|
||||
test("reveals a short file's match that the widget would cover", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
|
||||
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "touch.txt");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-cover-split-" });
|
||||
await writeFile(path.join(workspace.repoPath, "left.txt"), "left filler\n");
|
||||
await writeFile(path.join(workspace.repoPath, "right.txt"), "needle one\nplain\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "left.txt");
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await openFileFromExplorer(page, "right.txt");
|
||||
const right = source(page).filter({ hasText: "needle one" });
|
||||
await expect(right).toBeVisible();
|
||||
await right.click();
|
||||
await right.press("ControlOrMeta+f");
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("split-match-uncovered.png") });
|
||||
|
||||
await test.step("stays visible when Replace expands the widget", async () => {
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("hay");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("split-replace-uncovered.png") });
|
||||
});
|
||||
|
||||
await test.step("navigation and query focus still work", async () => {
|
||||
await query(page).press("ControlOrMeta+f");
|
||||
await expectQuerySelected(page, "needle");
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await expectActiveMatchUncovered(page);
|
||||
});
|
||||
await query(page).press("Escape");
|
||||
await expect(right).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("Go to line keeps its dialog with Find closed and open", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-goto-" });
|
||||
const lines = Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join("\n");
|
||||
await writeFile(path.join(workspace.repoPath, "lines.txt"), `${lines}\n`);
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "lines.txt");
|
||||
const gotoInput = page.getByRole("textbox", { name: "Go to line" });
|
||||
|
||||
await test.step("with Find closed", async () => {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+Alt+g");
|
||||
await expect(gotoInput).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("goto-line-find-closed.png") });
|
||||
await gotoInput.fill("12");
|
||||
await gotoInput.press("Enter");
|
||||
await expect(page.getByLabel("Line 12, column 1")).toBeVisible();
|
||||
await expect(gotoInput).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("with Find open", async () => {
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("line 3");
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+Alt+g");
|
||||
await expect(gotoInput).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("goto-line-find-open.png") });
|
||||
await gotoInput.fill("30");
|
||||
await gotoInput.press("Enter");
|
||||
await expect(page.getByLabel("Line 30, column 1")).toBeVisible();
|
||||
await expect(query(page)).toHaveValue("line 3");
|
||||
await expect(status(page)).toHaveText("11 matches");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdtemp, rm, symlink } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test as base, expect } from "../support/fixtures";
|
||||
import { prepareContentSearch } from "../support/helpers/workspace-content-search";
|
||||
|
||||
// The missing-rg fixture builds a PATH from `which` and POSIX symlinks. Windows needs its own
|
||||
// PATH construction, which belongs to the CI gate rather than to this journey.
|
||||
base.skip(process.platform === "win32", "the missing-ripgrep PATH fixture is POSIX-shaped");
|
||||
|
||||
const test = base.extend<{}, { searchHostPath: string }>({
|
||||
searchHostPath: [
|
||||
async ({ browserName }, provide) => {
|
||||
void browserName;
|
||||
const bin = await mkdtemp(path.join(os.tmpdir(), "search-host-path-"));
|
||||
await symlink(
|
||||
execFileSync("which", ["git"], { encoding: "utf8" }).trim(),
|
||||
path.join(bin, "git"),
|
||||
);
|
||||
await symlink(process.execPath, path.join(bin, "node"));
|
||||
try {
|
||||
await provide(bin);
|
||||
} finally {
|
||||
await rm(bin, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
e2eDaemonEnvironment: [
|
||||
async ({ searchHostPath }, provide) => {
|
||||
await provide({ PATH: searchHostPath });
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
||||
test("missing host ripgrep is actionable and Retry works after installation", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
searchHostPath,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await page.keyboard.press("Control+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("needle");
|
||||
await expect(
|
||||
page.getByText("Install ripgrep on this host and make rg available on its PATH, then retry", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByTestId("command-center-panel")
|
||||
.screenshot({ path: testInfo.outputPath("missing-rg.png") });
|
||||
await symlink(
|
||||
execFileSync("which", ["rg"], { encoding: "utf8" }).trim(),
|
||||
path.join(searchHostPath, "rg"),
|
||||
);
|
||||
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { writeFile, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
import { waitForTerminalContent } from "../support/helpers/terminal-perf";
|
||||
|
||||
test("content-search entry and dismissal preserve live shell input and terminal focus", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const harness = await TerminalE2EHarness.create({ tempPrefix: "content-search-terminal-" });
|
||||
try {
|
||||
const terminal = await harness.createTerminal({ name: "Search shortcut QA" });
|
||||
await writeFile(
|
||||
path.join(harness.tempRepo.path, "source.ts"),
|
||||
'export const greeting = "needle";\n',
|
||||
);
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await harness.setupPrompt(page);
|
||||
await searchFromPendingShellCommand(page, harness, testInfo.outputPath("terminal-search.png"));
|
||||
expect(await readFile(path.join(harness.tempRepo.path, "shell-input.txt"), "utf8")).toBe(
|
||||
"untouched",
|
||||
);
|
||||
} finally {
|
||||
await harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function searchFromPendingShellCommand(
|
||||
page: import("@playwright/test").Page,
|
||||
harness: TerminalE2EHarness,
|
||||
screenshot: string,
|
||||
) {
|
||||
const terminal = harness.terminalSurface(page);
|
||||
await terminal.pressSequentially("printf untouched > shell-input.txt", { delay: 0 });
|
||||
await page.keyboard.press("Control+Shift+F");
|
||||
const query = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await expect(query).toBeFocused();
|
||||
await query.pressSequentially("needle", { delay: 0 });
|
||||
await expect(page.getByRole("button", { name: /source.ts:1:26/ })).toBeVisible();
|
||||
await page.screenshot({ path: screenshot });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toBeHidden();
|
||||
await page.keyboard.press("Enter");
|
||||
await terminal.pressSequentially("printf '\\nSEARCH_SHELL_RESUMED\\n'\n", { delay: 0 });
|
||||
await waitForTerminalContent(page, (text) => text.includes("SEARCH_SHELL_RESUMED"), 10_000);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
prepareContentSearch,
|
||||
searchContents,
|
||||
previewAndOpenSecondOccurrence,
|
||||
openCompactPreview,
|
||||
preserveDirtyBufferAndUndo,
|
||||
recoverChangedAndMissingPreview,
|
||||
searchContentsByTouch,
|
||||
} from "../support/helpers/workspace-content-search";
|
||||
|
||||
test.use({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
|
||||
});
|
||||
test("searches unopened saved contents, previews keyboard selection and opens the exact occurrence", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await searchContents(page, "needle");
|
||||
await previewAndOpenSecondOccurrence(page, testInfo.outputPath("content-search-wide.png"));
|
||||
await preserveDirtyBufferAndUndo(page, workspace);
|
||||
});
|
||||
test("compact search keeps list, preview and Open in the existing popup", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await searchContentsByTouch(page, "needle");
|
||||
await openCompactPreview(page, testInfo.outputPath("content-search-compact.png"));
|
||||
});
|
||||
|
||||
test("changed saved files, empty results and occurrence limits stay explicit", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await searchContents(page, "needle");
|
||||
await recoverChangedAndMissingPreview(
|
||||
page,
|
||||
workspace,
|
||||
testInfo.outputPath("content-search-limited.png"),
|
||||
);
|
||||
});
|
||||
|
||||
test("a host without content search advertises an update requirement without sending the new RPC", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
gate.setWorkspaceContentSearchStripped();
|
||||
const workspace = await withWorkspace();
|
||||
await prepareContentSearch(workspace);
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await expect(
|
||||
page.getByText("Update this host to search file contents", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(
|
||||
page.getByText("Update this host to search file contents", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(gate.getClientRequestCount("fs.content.search.request")).toBe(0);
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(
|
||||
page.getByRole("textbox", {
|
||||
name: "Search commands, files, workspaces, and agents...",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Meta+p");
|
||||
await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
import { openLiteralSourceOccurrence } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("SVG image opening stays visual while a saved occurrence opens exact source", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await openLiteralSourceOccurrence(
|
||||
page,
|
||||
workspace,
|
||||
{
|
||||
path: "icon.svg",
|
||||
content: '<svg xmlns="http://www.w3.org/2000/svg"><title>needle</title></svg>',
|
||||
result: 'icon.svg:1:48 <svg xmlns="http://www.w3.org/2000/svg"><title>needle</title></svg>',
|
||||
imageFirst: true,
|
||||
},
|
||||
testInfo.outputPath("svg-source-open.png"),
|
||||
);
|
||||
});
|
||||
|
||||
test("literal leading-space file identity survives preview and exact tab activation", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await openLiteralSourceOccurrence(
|
||||
page,
|
||||
workspace,
|
||||
{
|
||||
path: " leading.txt",
|
||||
content: "needle",
|
||||
result: " leading.txt:1:1 needle",
|
||||
cursor: "Line 1, column 7",
|
||||
},
|
||||
testInfo.outputPath("literal-path-open.png"),
|
||||
);
|
||||
});
|
||||
|
||||
test("CR-only saved occurrences use the source editor line and exact selection", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace();
|
||||
await openLiteralSourceOccurrence(
|
||||
page,
|
||||
workspace,
|
||||
{
|
||||
path: "cr-only.txt",
|
||||
content: "first\rneedle",
|
||||
result: "cr-only.txt:2:1 needle",
|
||||
cursor: "Line 2, column 7",
|
||||
},
|
||||
testInfo.outputPath("cr-source-open.png"),
|
||||
);
|
||||
});
|
||||
|
||||
import {
|
||||
openLiteralBackslashFile,
|
||||
reportSkippedLargeFiles,
|
||||
} from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("an empty result says which files the host never opened", async ({ page, withWorkspace }) => {
|
||||
await reportSkippedLargeFiles(page, await withWorkspace());
|
||||
});
|
||||
|
||||
// "a\b.txt" is one file on POSIX and a nested path on Windows, so only this fixture is unavailable
|
||||
// there. The skip belongs to this test: at file scope it annotates the whole suite and takes every
|
||||
// other journey in this file with it. Windows separator handling is covered by
|
||||
// resolveWorkspaceFilePaths and toWorkspaceRelativePath.
|
||||
test("a file name containing a backslash opens itself, not a same-named nested path", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.skip(
|
||||
process.platform === "win32",
|
||||
"a file name containing a backslash is not a valid Windows fixture",
|
||||
);
|
||||
await openLiteralBackslashFile(page, await withWorkspace());
|
||||
});
|
||||
|
||||
import { reopenSameOccurrenceAfterMovingAway } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("opening the same occurrence again navigates the pane it is already showing", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await reopenSameOccurrenceAfterMovingAway(page, await withWorkspace());
|
||||
});
|
||||
|
||||
import { revealExactPathOnHover } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("a result's tooltip paints the exact path, however deep", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const { rows, tip } = await revealExactPathOnHover(page, await withWorkspace());
|
||||
await rows.first().hover();
|
||||
await expect(tip).toBeVisible();
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: testInfo.outputPath("tooltip-exact-path.png") });
|
||||
// Pointing must not move the keyboard selection: Enter still opens the first result.
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId("command-center-panel")).toBeHidden();
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("SHARED_UTIL");
|
||||
});
|
||||
|
||||
import { keepDesktopOverlayChrome } from "../support/helpers/workspace-content-search";
|
||||
|
||||
test("the desktop panel keeps its backdrop, Escape and focus return in every scope", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await keepDesktopOverlayChrome(page, await withWorkspace());
|
||||
});
|
||||
@@ -26,7 +26,7 @@ const SURFACE_SELECTORS = {
|
||||
input: '[data-testid="command-center-input"]',
|
||||
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
|
||||
results: '[data-testid="command-center-results"]',
|
||||
scope: '[data-testid="command-center-files-scope"]',
|
||||
scope: '[data-testid="command-center-scope"]',
|
||||
fileRow: '[data-testid^="command-center-file-row-"]',
|
||||
fileIcon: '[data-testid="command-center-file-icon"]',
|
||||
fileLine: '[data-testid="command-center-file-line"]',
|
||||
|
||||
@@ -104,19 +104,20 @@ function stripAssistantMessageId(
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function stripCanonicalSubmittedPrompts(
|
||||
function stripServerFeatures(
|
||||
message: string | Buffer,
|
||||
enabled: boolean,
|
||||
omittedFeatures: Set<string>,
|
||||
messageType: unknown,
|
||||
): string | Buffer {
|
||||
if (!enabled || messageType !== "status" || typeof message !== "string") return message;
|
||||
if (!omittedFeatures.size || messageType !== "status" || typeof message !== "string")
|
||||
return message;
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { payload?: { status?: unknown; features?: Record<string, unknown> } };
|
||||
payload?: { status?: unknown; features?: Record<string, unknown> };
|
||||
};
|
||||
const payload = envelope.message?.payload ?? envelope.payload;
|
||||
if (payload?.status !== "server_info" || !payload.features) return message;
|
||||
delete payload.features.canonicalSubmittedPrompts;
|
||||
for (const feature of omittedFeatures) delete payload.features[feature];
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
@@ -299,7 +300,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
let suppressAgentStream = false;
|
||||
let forceTimelineEpochReset = false;
|
||||
let stripAssistantMessageIds = false;
|
||||
let stripCanonicalSubmittedPromptsFeature = false;
|
||||
const omittedFeatures = new Set<string>();
|
||||
let shellToolCommandOverride: string | null = null;
|
||||
let failingTimelineAgentId: string | null = null;
|
||||
let holdingTimelineAgentId: string | null = null;
|
||||
@@ -509,11 +510,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
serverMessage?.type,
|
||||
);
|
||||
outboundMessage = rewriteShellToolCommand(outboundMessage, shellToolCommandOverride);
|
||||
outboundMessage = stripCanonicalSubmittedPrompts(
|
||||
outboundMessage,
|
||||
stripCanonicalSubmittedPromptsFeature,
|
||||
serverMessage?.type,
|
||||
);
|
||||
outboundMessage = stripServerFeatures(outboundMessage, omittedFeatures, serverMessage?.type);
|
||||
const isTimelineResponse = serverMessage?.type === "fetch_agent_timeline_response";
|
||||
if (isTimelineResponse) {
|
||||
outboundMessage = failTimelineResponse(outboundMessage, failingTimelineAgentId);
|
||||
@@ -823,8 +820,12 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
setAssistantMessageIdsStripped(stripped: boolean): void {
|
||||
stripAssistantMessageIds = stripped;
|
||||
},
|
||||
setWorkspaceContentSearchStripped(): void {
|
||||
omittedFeatures.add("workspaceContentSearch");
|
||||
},
|
||||
setCanonicalSubmittedPromptsStripped(stripped: boolean): void {
|
||||
stripCanonicalSubmittedPromptsFeature = stripped;
|
||||
if (stripped) omittedFeatures.add("canonicalSubmittedPrompts");
|
||||
else omittedFeatures.delete("canonicalSubmittedPrompts");
|
||||
},
|
||||
setShellToolCommandOverride(command: string | null): void {
|
||||
shellToolCommandOverride = command;
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { expect, test, type Page } from "../fixtures";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { scrollTimelineToOldestLoadedEdge } from "./timeline-pagination";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
countWorkspaceAgents,
|
||||
countWorkspaceTerminals,
|
||||
delayBrowserWorkspaceCreatedResponse,
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
selectWorkspaceIsolation,
|
||||
submitNewWorkspaceEmpty,
|
||||
submitNewWorkspacePrompt,
|
||||
waitForCreatedWorkspace,
|
||||
} from "./new-workspace";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { getServerId } from "./server-id";
|
||||
import {
|
||||
expectTerminalOutputContains,
|
||||
expectWorkspaceOpensWithTerminalTab,
|
||||
fillTerminalPrompt,
|
||||
seedTerminalProfiles,
|
||||
selectLaunchOption,
|
||||
submitTerminalLaunch,
|
||||
type TerminalProfile,
|
||||
} from "./new-workspace-launch";
|
||||
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./workspace-ui";
|
||||
import { dropFileOnComposer, expectAttachmentPill } from "./composer";
|
||||
|
||||
const PROMPT = "Hello from the navigation guard";
|
||||
const NEXT_PROMPT = "Keep this newer draft";
|
||||
const CONTEXT = {
|
||||
name: "context.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from('{"preserve":"attachment"}'),
|
||||
};
|
||||
const PROFILE: TerminalProfile = {
|
||||
id: "e2e-nav-guard-echo",
|
||||
name: "Nav Guard Echo",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo captured: "$0"; sleep 120', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
export async function verifyDelayedWorkspaceCreation(
|
||||
page: Page,
|
||||
launch: "chat" | "terminal" | "empty",
|
||||
destination: "leave" | "stay" | "new-draft",
|
||||
isolation: "local" | "worktree" = "local",
|
||||
): Promise<void> {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const repo = await createTempGitRepo("workspace-focus-");
|
||||
const profileSeed = await seedTerminalProfiles([PROFILE]);
|
||||
const delay = await delayBrowserWorkspaceCreatedResponse(page);
|
||||
const serverId = getServerId();
|
||||
let localWorkspaceId: string | undefined;
|
||||
let createdDirectory: string | undefined;
|
||||
let newDraftRoute = "";
|
||||
|
||||
try {
|
||||
const project = await openProjectViaDaemon(client, repo.path);
|
||||
localWorkspaceId = project.workspaceId;
|
||||
const knownIds = new Set((await client.fetchWorkspaces()).entries.map((entry) => entry.id));
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
|
||||
|
||||
await test.step("Submit workspace creation with the requested content", async () => {
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await selectWorkspaceIsolation(page, isolation);
|
||||
if (launch === "chat") {
|
||||
await dropFileOnComposer(page, CONTEXT);
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
await submitNewWorkspacePrompt(page, PROMPT);
|
||||
} else if (launch === "terminal") {
|
||||
await selectLaunchOption(page, PROFILE.id);
|
||||
await fillTerminalPrompt(page, PROMPT);
|
||||
await submitTerminalLaunch(page);
|
||||
} else {
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
}
|
||||
await delay.waitForCreateRequest();
|
||||
});
|
||||
|
||||
await test.step("Choose where to work while the creation response is held", async () => {
|
||||
if (destination !== "stay") {
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
|
||||
}
|
||||
if (destination === "new-draft") {
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await fillNewWorkspaceDraft(page, NEXT_PROMPT);
|
||||
const url = new URL(page.url());
|
||||
newDraftRoute = url.pathname + url.search;
|
||||
}
|
||||
// Recording pacing only; the response is held deterministically above.
|
||||
if (process.env.E2E_RECORD_VIDEO === "1") {
|
||||
await page.mouse.move(1100, 80);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
delay.release();
|
||||
});
|
||||
|
||||
const created = await waitForCreatedWorkspace(client, knownIds);
|
||||
createdDirectory = created.workspaceDirectory;
|
||||
expect(created.workspaceKind).toBe(isolation === "worktree" ? "worktree" : "local_checkout");
|
||||
await test.step("Completion creates exactly the requested resource and preserves focus", async () => {
|
||||
await expect
|
||||
.poll(() => countWorkspaceAgents(client, created.id))
|
||||
.toBe(launch === "chat" ? 1 : 0);
|
||||
await expect
|
||||
.poll(() => countWorkspaceTerminals(client, created.id))
|
||||
.toBe(launch === "terminal" ? 1 : 0);
|
||||
// A negative navigation assertion needs an observation window after completion.
|
||||
await page.waitForTimeout(5000);
|
||||
const expectedRoute =
|
||||
destination === "new-draft"
|
||||
? newDraftRoute
|
||||
: buildHostWorkspaceRoute(
|
||||
serverId,
|
||||
destination === "stay" ? created.id : project.workspaceId,
|
||||
);
|
||||
await expectAppRoute(page, expectedRoute);
|
||||
if (destination === "new-draft") await expectNewWorkspaceDraft(page, NEXT_PROMPT);
|
||||
if (launch === "chat") {
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
const upload = delay.agentRequests[0]?.attachments?.find(
|
||||
(item) => item.type === "uploaded_file",
|
||||
);
|
||||
if (!upload) throw new Error("The create request did not contain the submitted file");
|
||||
expect(await readFile(upload.path)).toEqual(CONTEXT.buffer);
|
||||
expect(delay.agentRequests[0]).toMatchObject({
|
||||
workspaceId: created.id,
|
||||
initialPrompt: PROMPT,
|
||||
config: {
|
||||
provider: "mock",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
thinkingOptionId: "low",
|
||||
cwd: created.workspaceDirectory,
|
||||
},
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
fileName: CONTEXT.name,
|
||||
mimeType: CONTEXT.mimeType,
|
||||
size: CONTEXT.buffer.length,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("Visit the new workspace and return without submitting a second draft", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
|
||||
if (launch === "chat") {
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await expect(page.getByText(PROMPT, { exact: true }).first()).toBeVisible();
|
||||
} else if (launch === "terminal") {
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, `captured: ${PROMPT}`);
|
||||
}
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
|
||||
await expect
|
||||
.poll(() => countWorkspaceAgents(client, created.id))
|
||||
.toBe(launch === "chat" ? 1 : 0);
|
||||
await expect
|
||||
.poll(() => countWorkspaceTerminals(client, created.id))
|
||||
.toBe(launch === "terminal" ? 1 : 0);
|
||||
if (launch === "chat") {
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await expectNewWorkspaceDraft(page, destination === "new-draft" ? NEXT_PROMPT : "");
|
||||
}
|
||||
if (process.env.E2E_RECORD_VIDEO === "1") {
|
||||
await page.mouse.move(1100, 80);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: test.info().outputPath("before-cleanup.png") });
|
||||
console.error(error);
|
||||
throw error;
|
||||
} finally {
|
||||
delay.release();
|
||||
if (createdDirectory) await archiveWorkspaceFromDaemon(client, createdDirectory);
|
||||
if (localWorkspaceId) await archiveLocalWorkspaceFromDaemon(client, localWorkspaceId);
|
||||
await profileSeed.restore();
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||
import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
@@ -17,7 +16,6 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
@@ -26,7 +24,6 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "enablePlugin"
|
||||
| "inspectWorkspaceRecovery"
|
||||
| "listProjects"
|
||||
| "listTerminals"
|
||||
| "on"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
@@ -625,120 +622,3 @@ export async function delayBrowserAgentCreatedStatus(
|
||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceCreatedDelayControl {
|
||||
agentRequests: readonly CreateAgentRequestMessage[];
|
||||
release(): void;
|
||||
waitForCreateRequest(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds `workspace.create.response` so a test can act as the user does while the daemon is still
|
||||
* running `git worktree add` — most importantly, navigate somewhere else.
|
||||
*/
|
||||
export async function delayBrowserWorkspaceCreatedResponse(
|
||||
page: Page,
|
||||
): Promise<WorkspaceCreatedDelayControl> {
|
||||
const daemonPortPattern = daemonWsRoutePattern();
|
||||
const agentRequests: CreateAgentRequestMessage[] = [];
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
let resolveCreateRequest: (() => void) | null = null;
|
||||
const createRequestSeen = new Promise<void>((resolve) => {
|
||||
resolveCreateRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request")
|
||||
agentRequests.push(sessionMessage as CreateAgentRequestMessage);
|
||||
if (sessionMessage?.type === "workspace.create.request") {
|
||||
const requestId = getStringField(sessionMessage, "requestId");
|
||||
if (requestId) {
|
||||
createRequestIds.add(requestId);
|
||||
resolveCreateRequest?.();
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload =
|
||||
sessionMessage?.type === "workspace.create.response" &&
|
||||
typeof sessionMessage.payload === "object"
|
||||
? (sessionMessage.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||
|
||||
if (requestId && createRequestIds.has(requestId) && !releaseRequested) {
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
agentRequests,
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreateRequest: () => createRequestSeen,
|
||||
};
|
||||
}
|
||||
|
||||
type WorkspaceEntry = Awaited<
|
||||
ReturnType<NewWorkspaceDaemonClient["fetchWorkspaces"]>
|
||||
>["entries"][number];
|
||||
|
||||
/**
|
||||
* Waits for a workspace the caller did not already know about, and returns it. Tests that create a
|
||||
* workspace without navigating to it have no route to assert on, so the daemon's own list is the
|
||||
* signal that creation finished.
|
||||
*/
|
||||
export async function waitForCreatedWorkspace(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
knownWorkspaceIds: ReadonlySet<string>,
|
||||
options?: { timeout?: number },
|
||||
): Promise<WorkspaceEntry> {
|
||||
let created: WorkspaceEntry | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
created = payload.entries.find((entry) => !knownWorkspaceIds.has(entry.id));
|
||||
return created !== undefined;
|
||||
},
|
||||
{ timeout: options?.timeout ?? 60_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
if (!created) {
|
||||
throw new Error("Workspace list reported a new workspace but it could not be read back");
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function countWorkspaceAgents(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<number> {
|
||||
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length;
|
||||
}
|
||||
|
||||
export async function countWorkspaceTerminals(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<number> {
|
||||
const payload = await client.listTerminals(undefined, undefined, { workspaceId });
|
||||
return payload.terminals.length;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { composerLocator } from "./composer";
|
||||
import type { CreatedWorkspace } from "./with-workspace";
|
||||
|
||||
export async function prepareContentSearch(workspace: CreatedWorkspace) {
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-a.ts"),
|
||||
'export const greeting = "NEEDLE";\n',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-b.ts"),
|
||||
'const emoji = "é🙂 needle needle";\n',
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
}
|
||||
export async function searchContents(page: Page, query: string) {
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(query);
|
||||
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
|
||||
}
|
||||
export async function previewAndOpenSecondOccurrence(page: Page, screenshot: string) {
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("export const greeting");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("const emoji");
|
||||
await expect(input).toBeFocused();
|
||||
// The query field is the Command Center's own header input, above the results and preview.
|
||||
const inputBox = await input.boundingBox();
|
||||
const previewBox = await panel.getByTestId("file-source-editor").boundingBox();
|
||||
expect(inputBox!.y + inputBox!.height).toBeLessThan(previewBox!.y);
|
||||
await panel.screenshot({ path: screenshot });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("const emoji");
|
||||
// The source view uses a real CodeMirror document; select-all is unnecessary and would hide a bad jump.
|
||||
await expect(page.getByLabel("Line 1, column 26", { exact: true })).toBeVisible();
|
||||
}
|
||||
export async function openCompactPreview(page: Page, screenshot: string) {
|
||||
await page.getByRole("button", { name: /search-a.ts:1:26/ }).click();
|
||||
await expect(page.getByRole("button", { name: "Back to results", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("command-center-panel").getByTestId("file-source-editor"),
|
||||
).toContainText("export const greeting");
|
||||
await page.getByTestId("command-center-panel").screenshot({ path: screenshot });
|
||||
await page.getByRole("button", { name: "Open", exact: true }).click();
|
||||
await expect(page.getByTestId("command-center-panel")).toBeHidden();
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("export const greeting");
|
||||
}
|
||||
|
||||
export async function preserveDirtyBufferAndUndo(page: Page, workspace: CreatedWorkspace) {
|
||||
const source = page.getByTestId("file-source-editor").locator(".cm-content");
|
||||
await source.fill("const localUnsaved = 42;\n");
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-b.ts"),
|
||||
'const emoji = "é🙂 needle needle";\n// changed on disk\n',
|
||||
);
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await searchContents(page, "needle");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(
|
||||
page.getByTestId("command-center-panel").getByTestId("file-source-editor"),
|
||||
).toContainText("changed on disk");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(source).toContainText("localUnsaved");
|
||||
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toContainText(
|
||||
"saved match is no longer at this location",
|
||||
);
|
||||
await source.press("Control+z");
|
||||
await expect(source).toContainText('const emoji = "é🙂 needle needle";');
|
||||
await expect(source).not.toContainText("localUnsaved");
|
||||
}
|
||||
|
||||
export async function recoverChangedAndMissingPreview(
|
||||
page: Page,
|
||||
workspace: CreatedWorkspace,
|
||||
screenshot: string,
|
||||
) {
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await writeFile(path.join(workspace.repoPath, "search-b.ts"), "changed after search\n");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(panel.getByRole("status")).toContainText(
|
||||
"saved match is no longer at this location",
|
||||
);
|
||||
await unlink(path.join(workspace.repoPath, "search-a.ts"));
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "search-a.ts"),
|
||||
'export const greeting = "NEEDLE";\n',
|
||||
);
|
||||
await panel.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("NEEDLE");
|
||||
await input.fill("not-present-anywhere");
|
||||
await expect(panel.getByText("No matches", { exact: true })).toBeVisible();
|
||||
await writeFile(path.join(workspace.repoPath, "many.txt"), "needle\n".repeat(250));
|
||||
await input.fill("needle");
|
||||
await expect(panel.getByText(/Results limited/)).toBeVisible();
|
||||
await panel.screenshot({ path: screenshot });
|
||||
}
|
||||
|
||||
export async function searchContentsByTouch(page: Page, query: string) {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Search", exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", {
|
||||
name: "Search commands, files, workspaces, and agents...",
|
||||
exact: true,
|
||||
})
|
||||
.fill("Search file contents");
|
||||
await page.getByText("Search file contents", { exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill(query);
|
||||
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openLiteralSourceOccurrence(
|
||||
page: Page,
|
||||
workspace: CreatedWorkspace,
|
||||
input: { path: string; content: string; result: string; cursor?: string; imageFirst?: boolean },
|
||||
screenshot: string,
|
||||
) {
|
||||
await writeFile(path.join(workspace.repoPath, input.path), input.content);
|
||||
await writeFile(path.join(workspace.repoPath, "leading.txt"), "different file");
|
||||
await workspace.navigateTo();
|
||||
if (input.imageFirst) {
|
||||
await page.keyboard.press("Meta+p");
|
||||
await page.getByRole("textbox", { name: "Search files...", exact: true }).fill(input.path);
|
||||
await page.getByTestId("command-center-panel").getByText(input.path, { exact: true }).click();
|
||||
await expect(page.getByTestId("image-file-preview")).toBeVisible();
|
||||
}
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("needle");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel.getByRole("button", { name: input.result, exact: true })).toBeVisible();
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("needle");
|
||||
await expect(panel.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
const source = page.getByTestId("file-source-editor");
|
||||
await expect(source).toContainText("needle");
|
||||
await expect(source).not.toContainText("different file");
|
||||
await expect(source.locator(".cm-selectionBackground")).toBeVisible();
|
||||
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
|
||||
if (input.cursor) await expect(page.getByLabel(input.cursor, { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: screenshot });
|
||||
}
|
||||
|
||||
export async function reportSkippedLargeFiles(page: Page, workspace: CreatedWorkspace) {
|
||||
// rg never opens a file above the host ceiling, so an unqualified "No matches" would claim the
|
||||
// whole workspace was read.
|
||||
await writeFile(path.join(workspace.repoPath, "large.txt"), `needle${" ".repeat(1024 * 1024)}`);
|
||||
await workspace.navigateTo();
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
await input.fill("needle");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel.getByText("No matches", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
panel.getByText("Files over 1.0 MB were not searched", { exact: true }),
|
||||
).toBeVisible();
|
||||
// The occurrence-cap notice is a different claim and must not appear here.
|
||||
await expect(panel.getByText(/Results limited/)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function openLiteralBackslashFile(page: Page, workspace: CreatedWorkspace) {
|
||||
// A backslash is an ordinary character in a Unix file name; "a\b.txt" is not "a/b.txt".
|
||||
await writeFile(path.join(workspace.repoPath, "a\\b.txt"), "needle LITERAL FILE");
|
||||
await mkdir(path.join(workspace.repoPath, "a"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "a", "b.txt"), "THIS IS THE OTHER FILE");
|
||||
await workspace.navigateTo();
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("needle");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel.getByRole("button", { name: /a\\b\.txt:1:1/ })).toBeVisible();
|
||||
await expect(panel.getByTestId("file-source-editor")).toContainText("LITERAL FILE");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
const source = page.getByTestId("file-source-editor");
|
||||
await expect(source).toContainText("LITERAL FILE");
|
||||
await expect(source).not.toContainText("THIS IS THE OTHER FILE");
|
||||
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening the same occurrence twice has to navigate the pane both times. The tab target is
|
||||
* deliberately identity-stable, so the second activation looks like a no-op unless it asks the
|
||||
* file-opening owner to navigate again.
|
||||
*/
|
||||
export async function reopenSameOccurrenceAfterMovingAway(page: Page, workspace: CreatedWorkspace) {
|
||||
const lines = Array.from(
|
||||
{ length: 1200 },
|
||||
(_value, index) => `export const filler${index} = ${index};`,
|
||||
);
|
||||
lines.push('const marker = "REOPEN_TARGET";');
|
||||
await writeFile(path.join(workspace.repoPath, "reopen.ts"), `${lines.join("\n")}\n`);
|
||||
await workspace.navigateTo();
|
||||
|
||||
const scroller = page.getByTestId("file-source-editor").locator(".cm-scroller");
|
||||
const offset = () => scroller.evaluate((element) => element.scrollTop);
|
||||
|
||||
await openReopenTarget(page);
|
||||
const revealed = await offset();
|
||||
expect(revealed).toBeGreaterThan(0);
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-selectionBackground"),
|
||||
).toBeInViewport();
|
||||
|
||||
// Move the caret to the top of the same file, inside the same editor instance.
|
||||
await page.getByTestId("file-source-editor").locator(".cm-content").click();
|
||||
await page.keyboard.press("ControlOrMeta+Home");
|
||||
await expect.poll(offset).toBeLessThan(revealed / 2);
|
||||
|
||||
await openReopenTarget(page);
|
||||
await expect
|
||||
.poll(offset, { message: "reopening the same occurrence must navigate the pane again" })
|
||||
.toBeGreaterThan(revealed / 2);
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-selectionBackground"),
|
||||
).toBeInViewport();
|
||||
// The editor instance is reused, so its history survives the second navigation.
|
||||
await expect(page.getByTestId("file-source-editor")).toContainText("REOPEN_TARGET");
|
||||
}
|
||||
|
||||
async function openReopenTarget(page: Page) {
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("REOPEN_TARGET");
|
||||
await expect(panel.getByRole("button", { name: /reopen\.ts:1201:/ })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel).toBeHidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Two files can share a base name and their nearest parent, and a real path is often wider than
|
||||
* the result column, so the exact workspace-relative path lives in the tooltip. Being in the DOM
|
||||
* is not enough: the tooltip has to be the thing painted at its own coordinates.
|
||||
*/
|
||||
export async function revealExactPathOnHover(page: Page, workspace: CreatedWorkspace) {
|
||||
const deep =
|
||||
"packages/app/src/command-center/workspace-content-search/internal/deeply/nested/result-path-presentation.ts";
|
||||
for (const relative of [
|
||||
"packages/app/src/utils/index.ts",
|
||||
"packages/server/src/utils/index.ts",
|
||||
deep,
|
||||
]) {
|
||||
const target = path.join(workspace.repoPath, relative);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, `export const shared = "SHARED_UTIL";\n`);
|
||||
}
|
||||
await workspace.navigateTo();
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
|
||||
.fill("SHARED_UTIL");
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const rows = panel.getByRole("button", { name: /:1:24/ });
|
||||
await expect(rows).toHaveCount(3);
|
||||
const tip = page.getByTestId("content-search-row-path");
|
||||
|
||||
async function expectPaintedPath(row: Locator, expected: string) {
|
||||
await row.hover();
|
||||
await expect(tip).toHaveText(expected);
|
||||
// The reader must actually see it: whatever is painted at the tooltip's own coordinates has
|
||||
// to be the tooltip, not the panel that used to cover it.
|
||||
// A tooltip is pointer-transparent, so elementFromPoint reports the row underneath either
|
||||
// way, and its surface is the same white as the panel. What settles it is whether the pixels
|
||||
// in its own rectangle change when it opens: if the panel still covers it, they do not.
|
||||
const box = await tip.boundingBox();
|
||||
if (!box) throw new Error("tooltip has no box");
|
||||
const clip = { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
const shown = await page.screenshot({ clip });
|
||||
await page.mouse.move(2, 2);
|
||||
await expect(tip).toBeHidden();
|
||||
const hidden = await page.screenshot({ clip });
|
||||
expect(
|
||||
shown.equals(hidden),
|
||||
"the tooltip's own rectangle must change when it opens, or the reader cannot see it",
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// Same base name and same nearest parent: the resting labels cannot tell these apart.
|
||||
const app = rows.filter({ hasText: "…/utils/index.ts:1:24" }).first();
|
||||
const server = rows.filter({ hasText: "…/utils/index.ts:1:24" }).last();
|
||||
await expect(app).toHaveAccessibleName(/^packages\/app\/src\/utils\/index\.ts:1:24 /);
|
||||
await expect(server).toHaveAccessibleName(/^packages\/server\/src\/utils\/index\.ts:1:24 /);
|
||||
await expectPaintedPath(app, "packages/app/src/utils/index.ts:1:24");
|
||||
await expectPaintedPath(server, "packages/server/src/utils/index.ts:1:24");
|
||||
|
||||
// A path far wider than the result column still reads in full.
|
||||
await expectPaintedPath(
|
||||
rows.filter({ hasText: "result-path-presentation.ts" }).first(),
|
||||
`${deep}:1:24`,
|
||||
);
|
||||
return { panel, rows, tip };
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlay chrome the desktop panel owns: a backdrop that dismisses it, Escape that dismisses
|
||||
* it, and focus that goes back where it came from. Hosting the panel in the shared overlay root
|
||||
* rather than a native Modal must not change any of it.
|
||||
*/
|
||||
export async function keepDesktopOverlayChrome(page: Page, workspace: CreatedWorkspace) {
|
||||
await writeFile(path.join(workspace.repoPath, "chrome.ts"), 'const a = "CHROME_TARGET";\n');
|
||||
await workspace.navigateTo();
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
|
||||
|
||||
// Backdrop dismisses the panel.
|
||||
const composer = composerLocator(page);
|
||||
await composer.click();
|
||||
await expect(composer).toBeFocused();
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await expect(input).toBeFocused();
|
||||
await page.mouse.click(page.viewportSize()!.width - 12, page.viewportSize()!.height - 12);
|
||||
await expect(panel).toBeHidden();
|
||||
|
||||
// Escape dismisses it and hands focus back to the composer it was opened from.
|
||||
await page.keyboard.press("Meta+Shift+F");
|
||||
await input.fill("CHROME_TARGET");
|
||||
await expect(panel.getByRole("button", { name: /chrome\.ts:1:/ })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
await expect(composer).toBeFocused();
|
||||
|
||||
// Ordinary commands and the Files scope still open over the same chrome.
|
||||
await page.keyboard.press("Meta+k");
|
||||
await expect(page.getByTestId("command-center-input")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Meta+p");
|
||||
await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkspaceContentSearch } from "./workspace-content-search";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
@@ -10,7 +11,8 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -36,6 +38,7 @@ import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
OverlayLayerProvider,
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
@@ -90,7 +93,18 @@ const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMut
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const SCOPE_LABELS = {
|
||||
files: {
|
||||
chip: "shell.commandCenter.files",
|
||||
placeholder: "shell.commandCenter.filePlaceholder",
|
||||
},
|
||||
content: {
|
||||
chip: "shell.commandCenter.content",
|
||||
placeholder: "shell.commandCenter.contentPlaceholder",
|
||||
},
|
||||
} as const;
|
||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||
const CONTENT_SEARCH_SNAP_POINTS = ["90%"];
|
||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||
|
||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
@@ -259,7 +273,7 @@ function useCommandCenterState(): CommandCenterState {
|
||||
error: fileSearchError,
|
||||
openFile,
|
||||
} = useWorkspaceFileSearch({
|
||||
enabled: open && (scope === "files" || Boolean(query.trim())),
|
||||
enabled: open && scope !== "content" && (scope === "files" || Boolean(query.trim())),
|
||||
query,
|
||||
});
|
||||
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
|
||||
@@ -584,6 +598,7 @@ export function CommandCenter() {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const contentKeyHandler = useRef<((key: string) => boolean) | null>(null);
|
||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||
const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
|
||||
@@ -700,18 +715,30 @@ export function CommandCenter() {
|
||||
onScroll: handleListScroll,
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
|
||||
// Every scope wears the same header; only the chip and the placeholder name it.
|
||||
const scopeLabels = state.scope ? SCOPE_LABELS[state.scope] : null;
|
||||
const scopeChipLabel = scopeLabels ? t(scopeLabels.chip) : null;
|
||||
const placeholder = t(scopeLabels?.placeholder ?? "shell.commandCenter.placeholder");
|
||||
// Content search owns arrows, Enter and Backspace while its scope is active; every other scope
|
||||
// is driven by the shared result list. One dispatcher so the input, submit and the web overlay
|
||||
// can never disagree about who is steering.
|
||||
const dispatchKey = useCallback(
|
||||
(key: string) =>
|
||||
state.scope === "content" ? (contentKeyHandler.current?.(key) ?? false) : state.key(key),
|
||||
[state],
|
||||
);
|
||||
const submit = useCallback(() => state.key("Enter"), [state]);
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => dispatchKey(key),
|
||||
[dispatchKey],
|
||||
);
|
||||
const submit = useCallback(() => dispatchKey("Enter"), [dispatchKey]);
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!state.key(event.key)) return false;
|
||||
if (!dispatchKey(event.key)) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
[state],
|
||||
[dispatchKey],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && state.open && !showBottomSheet,
|
||||
@@ -730,7 +757,9 @@ export function CommandCenter() {
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={COMMAND_CENTER_SNAP_POINTS}
|
||||
snapPoints={
|
||||
state.scope === "content" ? CONTENT_SEARCH_SNAP_POINTS : COMMAND_CENTER_SNAP_POINTS
|
||||
}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
@@ -744,9 +773,7 @@ export function CommandCenter() {
|
||||
accessible={false}
|
||||
>
|
||||
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
{scopeChipLabel ? <ScopeChip label={scopeChipLabel} onRemove={state.clearScope} /> : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
@@ -755,11 +782,7 @@ export function CommandCenter() {
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
placeholder={placeholder}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -770,49 +793,90 @@ export function CommandCenter() {
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
{state.scope === "content" ? (
|
||||
<WorkspaceContentSearch
|
||||
query={state.query}
|
||||
compact={isCompact}
|
||||
close={state.close}
|
||||
clearScope={state.clearScope}
|
||||
keyHandler={contentKeyHandler}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</>
|
||||
)}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
const desktopContent = (
|
||||
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
|
||||
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
initialValue={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<FlatList ref={listRef} {...commonListProps} />
|
||||
<View style={[styles.overlay, isWeb && { zIndex: modalLayer }]}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
testID="command-center-panel"
|
||||
style={[styles.panel, state.scope === "content" && styles.contentPanel]}
|
||||
>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{scopeChipLabel ? (
|
||||
<ScopeChip label={scopeChipLabel} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
initialValue={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={placeholder}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{state.scope === "content" ? (
|
||||
<WorkspaceContentSearch
|
||||
query={state.query}
|
||||
compact={isCompact}
|
||||
close={state.close}
|
||||
clearScope={state.clearScope}
|
||||
keyHandler={contentKeyHandler}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{fileSearchError}
|
||||
<FlatList ref={listRef} {...commonListProps} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
return hostDesktopOverlay(desktopContent, state.close);
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop web hosts the panel in the shared overlay root rather than React Native Web's `Modal`,
|
||||
* which paints in a plane of its own above it. Inside that plane an ordinary portal — the tooltip
|
||||
* on a result row — can never cover the panel however high its own z-index, so the row could not
|
||||
* show a reader its exact path. `adaptive-modal-sheet` already hosts desktop modals this way; see
|
||||
* docs/floating-panels.md. Native keeps the Modal.
|
||||
*/
|
||||
function hostDesktopOverlay(content: ReactElement, onRequestClose: () => void): ReactElement {
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
return (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={onRequestClose}>
|
||||
{content}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; label: string }) {
|
||||
@@ -841,7 +905,7 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
|
||||
accessibilityLabel={label}
|
||||
onPress={onRemove}
|
||||
style={styles.scopeChip}
|
||||
testID="command-center-files-scope"
|
||||
testID="command-center-scope"
|
||||
>
|
||||
<ThemedFolder size={14} strokeWidth={2.2} />
|
||||
<Text style={styles.scopeChipLabel}>{label}</Text>
|
||||
@@ -852,12 +916,14 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
paddingTop: theme.spacing[12],
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
||||
contentPanel: { width: 1000, maxWidth: "96%", height: "75%" },
|
||||
panel: {
|
||||
width: 640,
|
||||
height: 560,
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { FlatList, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { WorkspaceContentMatch } from "@getpaseo/protocol/messages";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { WorkspaceFileSource } from "@/file-pane/source";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import { describeResultPath } from "./internal/result-path";
|
||||
import {
|
||||
WorkspaceContentSearchModel,
|
||||
type ContentSearchSnapshot,
|
||||
type ContentSearchTransport,
|
||||
} from "./internal/model";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
/** The Command Center owns the field; this body only reacts to what was typed in it. */
|
||||
query: string;
|
||||
compact: boolean;
|
||||
close(): void;
|
||||
clearScope(): void;
|
||||
keyHandler: React.RefObject<((key: string) => boolean) | null>;
|
||||
}
|
||||
|
||||
export function WorkspaceContentSearch(props: Props) {
|
||||
const { keyHandler, close, clearScope } = props;
|
||||
const { t } = useTranslation();
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const workspaceId = selection?.workspaceId ?? null;
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const session = useSessionStore((state) => (serverId ? state.sessions[serverId] : undefined));
|
||||
const unavailable =
|
||||
!session?.client ||
|
||||
!cwd ||
|
||||
!serverId ||
|
||||
!workspaceId ||
|
||||
!session.serverInfo?.features?.workspaceContentSearch;
|
||||
useEffect(() => {
|
||||
if (!unavailable) return;
|
||||
keyHandler.current = (key) => {
|
||||
if (key === "Escape") close();
|
||||
else if (key === "Backspace") clearScope();
|
||||
return ["Enter", "ArrowUp", "ArrowDown", "Escape", "Backspace"].includes(key);
|
||||
};
|
||||
return () => {
|
||||
keyHandler.current = null;
|
||||
};
|
||||
}, [unavailable, keyHandler, close, clearScope]);
|
||||
if (!session?.client || !cwd || !serverId || !workspaceId)
|
||||
return <Text style={styles.status}>{t("shell.commandCenter.contentUnavailable")}</Text>;
|
||||
// COMPAT(workspaceContentSearch): added in v0.8.0, remove after 2027-03-09 once daemon floor >= v0.8.0.
|
||||
if (!session.serverInfo?.features?.workspaceContentSearch)
|
||||
return <Text style={styles.status}>{t("shell.commandCenter.contentUpdateHost")}</Text>;
|
||||
return (
|
||||
<ContentSearch
|
||||
key={`${serverId}:${workspaceId}`}
|
||||
{...props}
|
||||
transport={session.client}
|
||||
cwd={cwd}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentSearch({
|
||||
transport,
|
||||
cwd,
|
||||
serverId,
|
||||
workspaceId,
|
||||
query,
|
||||
compact,
|
||||
close,
|
||||
clearScope,
|
||||
keyHandler,
|
||||
}: Props & {
|
||||
transport: ContentSearchTransport;
|
||||
cwd: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const [previewPage, setPreviewPage] = useState(false);
|
||||
const closeRef = useRef(close);
|
||||
closeRef.current = close;
|
||||
const model = useMemo(
|
||||
() =>
|
||||
new WorkspaceContentSearchModel({
|
||||
cwd,
|
||||
transport,
|
||||
open(location) {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
const layout = useWorkspaceLayoutStore.getState();
|
||||
openWorkspaceFileInFocusedPane({
|
||||
location,
|
||||
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
closeExplorerAfterOpen: true,
|
||||
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
||||
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
|
||||
layout.openTab({ workspaceKey, target, intent: "reveal", placement }),
|
||||
focusWorkspaceTab: layout.focusTab,
|
||||
requestFileNavigation: layout.requestFileNavigation,
|
||||
});
|
||||
closeRef.current();
|
||||
},
|
||||
}),
|
||||
[cwd, transport, serverId, workspaceId],
|
||||
);
|
||||
useEffect(() => () => model.dispose(), [model]);
|
||||
// The field lives in the Command Center header, so the typed value is a prop; this is the only
|
||||
// bridge from it into the model that owns the search.
|
||||
useEffect(() => {
|
||||
setPreviewPage(false);
|
||||
model.setQuery(query);
|
||||
}, [model, query]);
|
||||
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const listRef = useRef<FlatList>(null);
|
||||
useEffect(() => {
|
||||
if (state.matches.length && (!compact || !previewPage))
|
||||
listRef.current?.scrollToIndex({
|
||||
index: state.activeIndex,
|
||||
animated: false,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, [compact, previewPage, state.activeIndex, state.matches]);
|
||||
const key = useCallback(
|
||||
(pressed: string) => {
|
||||
if (pressed === "Escape") {
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Backspace" && !state.query) {
|
||||
clearScope();
|
||||
return true;
|
||||
}
|
||||
if (pressed === "ArrowDown" || pressed === "ArrowUp") {
|
||||
model.move(pressed === "ArrowDown" ? 1 : -1);
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Enter") {
|
||||
model.openSelected();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[close, clearScope, model, state.query],
|
||||
);
|
||||
useEffect(() => {
|
||||
keyHandler.current = key;
|
||||
return () => {
|
||||
if (keyHandler.current === key) keyHandler.current = null;
|
||||
};
|
||||
}, [keyHandler, key]);
|
||||
const select = useCallback(
|
||||
(index: number) => {
|
||||
model.select(index);
|
||||
if (compact) setPreviewPage(true);
|
||||
},
|
||||
[model, compact],
|
||||
);
|
||||
const back = useCallback(() => setPreviewPage(false), []);
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: WorkspaceContentMatch; index: number }) => (
|
||||
<ResultRow item={item} index={index} selected={state.activeIndex === index} select={select} />
|
||||
),
|
||||
[select, state.activeIndex],
|
||||
);
|
||||
const emptyState = useMemo(
|
||||
() => <SearchState state={state} retry={model.retry} />,
|
||||
[model.retry, state],
|
||||
);
|
||||
const limitNotice = useMemo(() => <ResultLimit limited={state.limited} />, [state.limited]);
|
||||
return (
|
||||
<View style={styles.body}>
|
||||
{!compact || !previewPage ? (
|
||||
<View style={[styles.results, compact && styles.full]}>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={state.matches}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyExtractor={resultKey}
|
||||
getItemLayout={resultLayout}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={emptyState}
|
||||
ListFooterComponent={limitNotice}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{!compact || previewPage ? (
|
||||
<ContentPreview state={state} model={model} compact={compact} back={back} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
item,
|
||||
index,
|
||||
selected,
|
||||
select,
|
||||
}: {
|
||||
item: WorkspaceContentMatch;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
select(index: number): void;
|
||||
}) {
|
||||
const onPress = useCallback(() => select(index), [index, select]);
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
// Same hover/selection idiom as the Command Center's own result rows, one tint apart so the
|
||||
// keyboard cursor that drives the preview stays distinguishable from the pointer.
|
||||
const style = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
(Boolean(hovered) || pressed) && styles.hovered,
|
||||
selected && styles.selected,
|
||||
],
|
||||
[selected],
|
||||
);
|
||||
const line = useMemo(() => describeMatchLine(item), [item]);
|
||||
const file = useMemo(() => describeResultPath(item.path), [item.path]);
|
||||
const location = `${item.path}:${item.line}:${item.columnStart}`;
|
||||
return (
|
||||
// The resting row shows the file and its nearest parent, which two files can share
|
||||
// (packages/app/src/utils/index.ts and packages/server/src/utils/index.ts). The tooltip is
|
||||
// where the exact workspace-relative path lives; it is not bounded by the column's width.
|
||||
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${location} ${item.snippet}`}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-pressed={isWeb ? selected : undefined}
|
||||
onPress={onPress}
|
||||
style={style}
|
||||
>
|
||||
<Text style={styles.snippet} numberOfLines={1}>
|
||||
{line.before}
|
||||
<Text style={styles.match}>{line.match}</Text>
|
||||
{line.after}
|
||||
</Text>
|
||||
{/* The name and coordinates are pinned; only the parent context may shrink, so a deep
|
||||
path loses its middle rather than the identity the reader is scanning for. */}
|
||||
<View style={styles.location}>
|
||||
{file.directory ? (
|
||||
<Text style={styles.locationDirectory} numberOfLines={1}>
|
||||
{file.directory}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={styles.locationFile} numberOfLines={1}>
|
||||
{file.name}:{item.line}:{item.columnStart}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" maxWidth={520} testID="content-search-row-path">
|
||||
<Text style={styles.tooltipPath}>{location}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** The row is one line wide, so the match has to survive indentation and long-line offsets. */
|
||||
function describeMatchLine(item: WorkspaceContentMatch) {
|
||||
// Only a run-up long enough to be worth cutting is cut, so an ordinary line keeps its start.
|
||||
const cut = item.snippetMatchStart > SNIPPET_LEAD * 2;
|
||||
const from = cut ? item.snippetMatchStart - SNIPPET_LEAD : 0;
|
||||
const before = item.snippet.slice(from, item.snippetMatchStart);
|
||||
return {
|
||||
before: cut ? `…${before}` : before.trimStart(),
|
||||
match: item.snippet.slice(item.snippetMatchStart, item.snippetMatchEnd),
|
||||
after: item.snippet.slice(item.snippetMatchEnd),
|
||||
};
|
||||
}
|
||||
function resultKey(item: WorkspaceContentMatch) {
|
||||
return `${item.path}:${item.line}:${item.columnStart}`;
|
||||
}
|
||||
function resultLayout(_data: ArrayLike<WorkspaceContentMatch> | null | undefined, index: number) {
|
||||
return { length: RESULT_ROW_HEIGHT, offset: index * RESULT_ROW_HEIGHT, index };
|
||||
}
|
||||
|
||||
/** Idle stays blank: the header placeholder already says what the field does. */
|
||||
function SearchState({ state, retry }: { state: ContentSearchSnapshot; retry(): void }) {
|
||||
const { t } = useTranslation();
|
||||
if (state.status === "searching")
|
||||
return (
|
||||
<View style={styles.status} accessibilityLiveRegion="polite">
|
||||
<ThemedSpinner size={14} />
|
||||
<Text style={styles.muted}>{t("shell.commandCenter.searchingFiles")}</Text>
|
||||
</View>
|
||||
);
|
||||
if (state.status === "error")
|
||||
return (
|
||||
<View style={styles.status} accessibilityLiveRegion="polite">
|
||||
<Text style={[styles.muted, styles.error]}>{state.message}</Text>
|
||||
<Button variant="outline" size="sm" onPress={retry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
if (state.status === "ready")
|
||||
return (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>
|
||||
{/* "No matches" alone claims the whole workspace was read. Files above the host's ceiling
|
||||
are never opened, so the empty state has to say so where the claim is made. */}
|
||||
{state.maxFileBytes ? (
|
||||
<Text style={styles.emptyDetail}>
|
||||
{t("shell.commandCenter.contentSkippedLargeFiles", {
|
||||
size: formatFileSize(state.maxFileBytes),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function ResultLimit({ limited }: { limited: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!limited) return null;
|
||||
return <Text style={styles.limit}>{t("shell.commandCenter.contentLimited")}</Text>;
|
||||
}
|
||||
|
||||
function ContentPreview({
|
||||
state,
|
||||
model,
|
||||
compact,
|
||||
back,
|
||||
}: {
|
||||
state: ContentSearchSnapshot;
|
||||
model: WorkspaceContentSearchModel;
|
||||
compact: boolean;
|
||||
back(): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const match = state.matches[state.activeIndex];
|
||||
const preview = state.preview;
|
||||
const location = useMemo(
|
||||
() =>
|
||||
match
|
||||
? {
|
||||
path: match.path,
|
||||
lineStart: match.line,
|
||||
columnStart: match.columnStart,
|
||||
columnEnd: match.columnEnd,
|
||||
expectedText: match.text,
|
||||
}
|
||||
: null,
|
||||
[match],
|
||||
);
|
||||
return (
|
||||
<View style={styles.preview}>
|
||||
{/* On desktop the selected row already names the file and Enter opens it, so the preview
|
||||
carries no header. The compact flow pages away from the list and still needs both. */}
|
||||
{compact && match ? (
|
||||
<View style={styles.previewHeader}>
|
||||
<Button variant="ghost" size="sm" onPress={back}>
|
||||
{t("shell.commandCenter.contentBack")}
|
||||
</Button>
|
||||
<Text style={styles.previewPath} numberOfLines={1} ellipsizeMode="head">
|
||||
{match.path}
|
||||
</Text>
|
||||
<Button variant="outline" size="sm" onPress={model.openSelected}>
|
||||
{t("shell.commandCenter.contentOpen")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{preview.status === "loading" ? (
|
||||
<View
|
||||
style={styles.status}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={t("shell.commandCenter.contentLoadingPreview")}
|
||||
>
|
||||
<ThemedSpinner size={14} />
|
||||
</View>
|
||||
) : null}
|
||||
{preview.status === "error" ? (
|
||||
<View style={styles.status} accessibilityLiveRegion="polite">
|
||||
<Text style={[styles.muted, styles.error]}>{preview.message}</Text>
|
||||
<Button variant="outline" size="sm" onPress={model.retryPreview}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{preview.status === "ready" && location ? (
|
||||
<WorkspaceFileSource
|
||||
content={preview.content}
|
||||
filename={preview.path}
|
||||
size={preview.size}
|
||||
navigationRevision={state.activeIndex}
|
||||
location={location}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Denser than the Command Center's own two-line row: these results are scanned, not read, and a
|
||||
// search wants as many occurrences on screen as stay legible.
|
||||
const RESULT_ROW_HEIGHT = 44;
|
||||
// Characters of the matched line kept ahead of the match itself.
|
||||
const SNIPPET_LEAD = 24;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
body: { flex: 1, minHeight: 0, flexDirection: "row" },
|
||||
// A result row is two truncated single lines, so extra panel width buys the list nothing and
|
||||
// buys the code preview everything: the column is a fixed reading width, capped so a narrow
|
||||
// desktop panel cannot starve the preview.
|
||||
results: {
|
||||
width: 360,
|
||||
maxWidth: "45%",
|
||||
minHeight: 0,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
},
|
||||
full: { width: "100%", maxWidth: "100%", borderRightWidth: 0 },
|
||||
row: {
|
||||
height: RESULT_ROW_HEIGHT,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
hovered: { backgroundColor: theme.colors.surface1 },
|
||||
selected: { backgroundColor: theme.colors.surface2 },
|
||||
// Three levels: the match is what the reader is looking for, the rest of the line is the
|
||||
// context it sits in, and the location is context for that.
|
||||
snippet: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: 17,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
match: {
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: "600",
|
||||
backgroundColor: theme.colors.terminal.selectionBackground,
|
||||
},
|
||||
location: { flexDirection: "row", alignItems: "baseline" },
|
||||
locationFile: {
|
||||
flexShrink: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 15,
|
||||
},
|
||||
locationDirectory: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 15,
|
||||
},
|
||||
preview: { flex: 1, minWidth: 0, minHeight: 0 },
|
||||
previewHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
previewPath: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
status: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
muted: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
|
||||
error: { color: theme.colors.statusDanger },
|
||||
tooltipPath: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
empty: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
emptyDetail: {
|
||||
textAlign: "center",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
limit: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,234 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { FileReadResult } from "@getpaseo/client";
|
||||
import type {
|
||||
WorkspaceContentMatch,
|
||||
WorkspaceContentSearchResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { WorkspaceContentSearchModel, type ContentSearchTransport } from "./model";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function match(path: string, text = "NEEDLE"): WorkspaceContentMatch {
|
||||
return {
|
||||
path,
|
||||
line: 1,
|
||||
columnStart: 1,
|
||||
columnEnd: text.length + 1,
|
||||
text,
|
||||
snippet: text,
|
||||
snippetMatchStart: 0,
|
||||
snippetMatchEnd: text.length,
|
||||
};
|
||||
}
|
||||
function result(matches: WorkspaceContentMatch[]): WorkspaceContentSearchResult {
|
||||
return { status: "ok", matches, limited: false };
|
||||
}
|
||||
function file(path: string, text: string): FileReadResult {
|
||||
return {
|
||||
path,
|
||||
kind: "text",
|
||||
mime: "text/plain",
|
||||
bytes: new TextEncoder().encode(text),
|
||||
size: text.length,
|
||||
modifiedAt: "2026-09-09",
|
||||
};
|
||||
}
|
||||
|
||||
test("obsolete queries cannot restore results and opening carries the original case-insensitive match", async () => {
|
||||
const first = deferred<WorkspaceContentSearchResult>();
|
||||
const second = deferred<WorkspaceContentSearchResult>();
|
||||
const startedFirst = deferred<void>();
|
||||
const startedSecond = deferred<void>();
|
||||
const signals: AbortSignal[] = [];
|
||||
const opened: unknown[] = [];
|
||||
const transport: ContentSearchTransport = {
|
||||
searchWorkspaceContent: ({ query }, { signal }) => {
|
||||
signals.push(signal);
|
||||
if (query === "old") {
|
||||
startedFirst.resolve();
|
||||
return first.promise;
|
||||
}
|
||||
startedSecond.resolve();
|
||||
return second.promise;
|
||||
},
|
||||
readFile: async (path) => file(path, "NEEDLE"),
|
||||
};
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
transport,
|
||||
open: (location) => opened.push(location),
|
||||
debounceMs: 0,
|
||||
});
|
||||
try {
|
||||
model.setQuery("old");
|
||||
await startedFirst.promise;
|
||||
model.setQuery("needle");
|
||||
await startedSecond.promise;
|
||||
expect(signals[0].aborted).toBe(true);
|
||||
second.resolve(result([match("a.ts")]));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
first.resolve(result([match("obsolete.ts")]));
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
query: "needle",
|
||||
status: "ready",
|
||||
matches: [match("a.ts")],
|
||||
preview: { status: "ready", content: "NEEDLE" },
|
||||
});
|
||||
model.openSelected();
|
||||
expect(opened).toEqual([
|
||||
{ path: "a.ts", lineStart: 1, columnStart: 1, columnEnd: 7, expectedText: "NEEDLE" },
|
||||
]);
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("selection bounds preview work to one read plus the latest target, and reads current saved content", async () => {
|
||||
const started = deferred<void>();
|
||||
const searched = deferred<WorkspaceContentSearchResult>();
|
||||
const a = deferred<FileReadResult>();
|
||||
const c = deferred<FileReadResult>();
|
||||
const paths: string[] = [];
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: () => {
|
||||
started.resolve();
|
||||
return searched.promise;
|
||||
},
|
||||
readFile: (_cwd, path) => {
|
||||
paths.push(path);
|
||||
return path === "a" ? a.promise : c.promise;
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("needle");
|
||||
await started.promise;
|
||||
searched.resolve(result([match("a"), match("b"), match("c")]));
|
||||
await Promise.resolve();
|
||||
model.select(1);
|
||||
model.select(2);
|
||||
expect(paths).toEqual(["a"]);
|
||||
a.resolve(file("a", "NEEDLE"));
|
||||
await Promise.resolve();
|
||||
expect(paths).toEqual(["a", "c"]);
|
||||
c.resolve(file("c", "changed"));
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().preview).toMatchObject({
|
||||
status: "ready",
|
||||
path: "c",
|
||||
content: "changed",
|
||||
});
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new query refreshes the saved preview even when its selected path is unchanged", async () => {
|
||||
const reads: Array<ReturnType<typeof deferred<FileReadResult>>> = [];
|
||||
const searches: Array<ReturnType<typeof deferred<WorkspaceContentSearchResult>>> = [];
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: () => {
|
||||
const pending = deferred<WorkspaceContentSearchResult>();
|
||||
searches.push(pending);
|
||||
return pending.promise;
|
||||
},
|
||||
readFile: () => {
|
||||
const pending = deferred<FileReadResult>();
|
||||
reads.push(pending);
|
||||
return pending.promise;
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("first");
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
searches[0].resolve(result([match("same.ts")]));
|
||||
await Promise.resolve();
|
||||
model.setQuery("second");
|
||||
await expect.poll(() => searches.length).toBe(2);
|
||||
searches[1].resolve(result([match("same.ts")]));
|
||||
await Promise.resolve();
|
||||
reads[0].resolve(file("same.ts", "old saved text"));
|
||||
await Promise.resolve();
|
||||
expect(reads).toHaveLength(2);
|
||||
reads[1].resolve(file("same.ts", "NEEDLE current saved text"));
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().preview).toMatchObject({
|
||||
status: "ready",
|
||||
content: "NEEDLE current saved text",
|
||||
});
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("textual image occurrences expose saved source without changing the read kind", async () => {
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: async () => result([match("icon.svg")]),
|
||||
readFile: async () => ({
|
||||
...file("icon.svg", "<svg>NEEDLE</svg>"),
|
||||
kind: "image",
|
||||
mime: "image/svg+xml",
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("needle");
|
||||
await expect.poll(() => model.getSnapshot().preview.status).toBe("ready");
|
||||
expect(model.getSnapshot().preview).toMatchObject({ content: "<svg>NEEDLE</svg>" });
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("revisiting a file re-reads it, so a file deleted since the search stops previewing", async () => {
|
||||
const reads: string[] = [];
|
||||
let deleted = false;
|
||||
const model = new WorkspaceContentSearchModel({
|
||||
cwd: "/workspace",
|
||||
debounceMs: 0,
|
||||
open() {},
|
||||
transport: {
|
||||
searchWorkspaceContent: async () => result([match("a.ts"), match("b.ts")]),
|
||||
readFile: async (_cwd, path) => {
|
||||
reads.push(path);
|
||||
if (path === "a.ts" && deleted) throw new Error("ENOENT: no such file");
|
||||
return file(path, `NEEDLE in ${path}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
model.setQuery("needle");
|
||||
await expect.poll(() => model.getSnapshot().preview.status).toBe("ready");
|
||||
expect(reads).toEqual(["a.ts"]);
|
||||
model.move(1);
|
||||
await expect.poll(() => reads).toEqual(["a.ts", "b.ts"]);
|
||||
deleted = true;
|
||||
model.move(-1);
|
||||
await expect.poll(() => model.getSnapshot().preview).toMatchObject({ status: "error" });
|
||||
expect(reads).toEqual(["a.ts", "b.ts", "a.ts"]);
|
||||
} finally {
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
WorkspaceContentMatch,
|
||||
WorkspaceContentSearchResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import type { FileReadResult } from "@getpaseo/client";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
|
||||
export interface ContentSearchTransport {
|
||||
searchWorkspaceContent(
|
||||
input: { cwd: string; query: string },
|
||||
options: { signal: AbortSignal },
|
||||
): Promise<WorkspaceContentSearchResult>;
|
||||
readFile(
|
||||
cwd: string,
|
||||
path: string,
|
||||
requestId?: string,
|
||||
maxBytes?: number,
|
||||
): Promise<FileReadResult>;
|
||||
}
|
||||
type Preview =
|
||||
| { status: "empty" | "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "ready"; path: string; content: string; size: number };
|
||||
export interface ContentSearchSnapshot {
|
||||
query: string;
|
||||
status: "idle" | "searching" | "ready" | "error";
|
||||
message: string;
|
||||
matches: WorkspaceContentMatch[];
|
||||
limited: boolean;
|
||||
/** Per-file byte ceiling the host applied, so an empty result can say what it did not read. */
|
||||
maxFileBytes: number | null;
|
||||
activeIndex: number;
|
||||
preview: Preview;
|
||||
}
|
||||
|
||||
/** The daemon refuses to read more, and rg refuses to search a file bigger than this. */
|
||||
const MAX_PREVIEW_BYTES = 1_048_576;
|
||||
export class WorkspaceContentSearchModel {
|
||||
private snapshot: ContentSearchSnapshot = {
|
||||
query: "",
|
||||
status: "idle",
|
||||
message: "",
|
||||
matches: [],
|
||||
limited: false,
|
||||
maxFileBytes: null,
|
||||
activeIndex: 0,
|
||||
preview: { status: "empty" },
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
private controller: AbortController | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private disposed = false;
|
||||
private reading = false;
|
||||
private file: { path: string; content: string; size: number } | null = null;
|
||||
constructor(
|
||||
private readonly options: {
|
||||
cwd: string;
|
||||
transport: ContentSearchTransport;
|
||||
open(location: WorkspaceFileLocation): void;
|
||||
debounceMs?: number;
|
||||
},
|
||||
) {}
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
getSnapshot = () => this.snapshot;
|
||||
private update(next: Partial<ContentSearchSnapshot>) {
|
||||
this.snapshot = { ...this.snapshot, ...next };
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
setQuery = (query: string) => {
|
||||
this.controller?.abort();
|
||||
this.file = null;
|
||||
clearTimeout(this.timer);
|
||||
this.update({
|
||||
query,
|
||||
status: query ? "searching" : "idle",
|
||||
message: "",
|
||||
matches: [],
|
||||
limited: false,
|
||||
maxFileBytes: null,
|
||||
activeIndex: 0,
|
||||
preview: { status: "empty" },
|
||||
});
|
||||
if (!query || this.disposed) return;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
this.timer = setTimeout(
|
||||
() => void this.search(query, controller),
|
||||
this.options.debounceMs ?? 100,
|
||||
);
|
||||
};
|
||||
private async search(query: string, controller: AbortController) {
|
||||
try {
|
||||
const result = await this.options.transport.searchWorkspaceContent(
|
||||
{ cwd: this.options.cwd, query },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (this.disposed || controller.signal.aborted) return;
|
||||
if (result.status === "error") {
|
||||
this.update({ status: "error", message: result.message });
|
||||
return;
|
||||
}
|
||||
this.update({
|
||||
status: "ready",
|
||||
matches: result.matches,
|
||||
limited: result.limited,
|
||||
maxFileBytes: result.maxFileBytes ?? null,
|
||||
});
|
||||
this.select(0);
|
||||
} catch (error) {
|
||||
if (!this.disposed && !controller.signal.aborted)
|
||||
this.update({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "File search failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
select = (index: number) => {
|
||||
const activeIndex = Math.max(0, Math.min(index, this.snapshot.matches.length - 1));
|
||||
this.update({ activeIndex });
|
||||
void this.preview();
|
||||
};
|
||||
move = (direction: number) =>
|
||||
this.select(
|
||||
(this.snapshot.activeIndex + direction + this.snapshot.matches.length) %
|
||||
(this.snapshot.matches.length || 1),
|
||||
);
|
||||
private async preview() {
|
||||
const match = this.snapshot.matches[this.snapshot.activeIndex];
|
||||
if (!match) return;
|
||||
// Only the file on screen is retained. Re-reading on every arrival is what lets the preview
|
||||
// tell the truth about a file that changed or disappeared since the search ran; see the
|
||||
// deleted-file recovery case in the browser spec before trading it for fewer reads.
|
||||
if (this.file?.path === match.path) {
|
||||
this.update({ preview: { status: "ready", ...this.file } });
|
||||
return;
|
||||
}
|
||||
this.update({ preview: { status: "loading" } });
|
||||
if (this.reading) return;
|
||||
this.reading = true;
|
||||
const path = match.path;
|
||||
const query = this.controller;
|
||||
try {
|
||||
const file = await this.options.transport.readFile(
|
||||
this.options.cwd,
|
||||
path,
|
||||
undefined,
|
||||
MAX_PREVIEW_BYTES,
|
||||
);
|
||||
if (this.disposed) return;
|
||||
if (query === this.controller) {
|
||||
const content = explorerFileFromReadResult(file).content;
|
||||
if (content === undefined) throw new Error("This file is no longer text");
|
||||
this.file = { path, content, size: file.size };
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!this.disposed &&
|
||||
query === this.controller &&
|
||||
this.snapshot.matches[this.snapshot.activeIndex]?.path === path
|
||||
) {
|
||||
this.update({
|
||||
preview: {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "Preview unavailable",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
this.reading = false;
|
||||
}
|
||||
if (!this.disposed) void this.preview();
|
||||
}
|
||||
retry = () => {
|
||||
this.file = null;
|
||||
this.setQuery(this.snapshot.query);
|
||||
};
|
||||
retryPreview = () => {
|
||||
this.file = null;
|
||||
void this.preview();
|
||||
};
|
||||
openSelected = () => {
|
||||
const match = this.snapshot.matches[this.snapshot.activeIndex];
|
||||
if (!match || this.snapshot.status !== "ready") return;
|
||||
this.options.open({
|
||||
path: match.path,
|
||||
lineStart: match.line,
|
||||
columnStart: match.columnStart,
|
||||
columnEnd: match.columnEnd,
|
||||
expectedText: match.text,
|
||||
});
|
||||
};
|
||||
dispose = () => {
|
||||
this.disposed = true;
|
||||
this.controller?.abort();
|
||||
clearTimeout(this.timer);
|
||||
this.listeners.clear();
|
||||
this.file = null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeResultPath } from "./result-path";
|
||||
|
||||
describe("describeResultPath", () => {
|
||||
it("keeps a root file whole", () => {
|
||||
expect(describeResultPath("README.md")).toEqual({ directory: "", name: "README.md" });
|
||||
});
|
||||
|
||||
it("keeps a shallow path whole", () => {
|
||||
expect(describeResultPath("docs/architecture.md")).toEqual({
|
||||
directory: "docs/",
|
||||
name: "architecture.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("elides the head of a deep path and keeps the distinguishing parent", () => {
|
||||
// These two rows are both "index.tsx"; the parents are the only thing telling them apart.
|
||||
expect(
|
||||
describeResultPath("packages/app/src/command-center/workspace-content-search/index.tsx"),
|
||||
).toEqual({ directory: "…/workspace-content-search/", name: "index.tsx" });
|
||||
expect(describeResultPath("packages/app/src/file-pane/source/index.tsx")).toEqual({
|
||||
directory: "…/source/",
|
||||
name: "index.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a literal backslash in a file name out of the directory", () => {
|
||||
expect(describeResultPath("a\\b.txt")).toEqual({ directory: "", name: "a\\b.txt" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* How a result row names its file.
|
||||
*
|
||||
* A flat list of occurrences is full of repeated base names — four `index.tsx` rows tell the
|
||||
* reader nothing. The nearest directories are what distinguish them, and they are also what an
|
||||
* end-truncated path throws away first. So the row keeps the file name and the closest parents,
|
||||
* and marks the elided head with a leading ellipsis.
|
||||
*/
|
||||
export interface ResultPathParts {
|
||||
/** Directory context immediately above the file, already elided at the head when deeper. */
|
||||
directory: string;
|
||||
/** File name, which is never truncated away. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
const PARENTS_KEPT = 1;
|
||||
|
||||
export function describeResultPath(path: string): ResultPathParts {
|
||||
const segments = path.split("/");
|
||||
const name = segments.pop() ?? path;
|
||||
if (segments.length === 0) return { directory: "", name };
|
||||
const kept = segments.slice(-PARENTS_KEPT);
|
||||
const elided = segments.length > kept.length;
|
||||
return { directory: `${elided ? "…/" : ""}${kept.join("/")}/`, name };
|
||||
}
|
||||
@@ -4,12 +4,12 @@ export interface WorkspaceFileSearchEntry {
|
||||
directory: string;
|
||||
}
|
||||
|
||||
/** Splits a workspace-relative path the host produced, where "/" is the only separator. */
|
||||
export function describeWorkspaceFilePath(path: string): WorkspaceFileSearchEntry {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const separator = normalized.lastIndexOf("/");
|
||||
const separator = path.lastIndexOf("/");
|
||||
return {
|
||||
path: normalized,
|
||||
name: separator >= 0 ? normalized.slice(separator + 1) : normalized,
|
||||
directory: separator >= 0 ? normalized.slice(0, separator) : "",
|
||||
path,
|
||||
name: separator >= 0 ? path.slice(separator + 1) : path,
|
||||
directory: separator >= 0 ? path.slice(0, separator) : "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ describe("describeWorkspaceFilePath", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes Windows separators before opening or presenting a file", () => {
|
||||
expect(describeWorkspaceFilePath("src\\components\\message.tsx")).toEqual({
|
||||
path: "src/components/message.tsx",
|
||||
name: "message.tsx",
|
||||
directory: "src/components",
|
||||
it("treats a backslash as part of the file name, because the host already sent separators as /", () => {
|
||||
expect(describeWorkspaceFilePath("a\\b.txt")).toEqual({
|
||||
path: "a\\b.txt",
|
||||
name: "a\\b.txt",
|
||||
directory: "",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -128,8 +128,8 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
||||
(path: string) => {
|
||||
if (!serverId || !workspaceId) return;
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
openWorkspaceFileFromExplorer({
|
||||
filePath: path,
|
||||
openWorkspaceFileInFocusedPane({
|
||||
location: { path },
|
||||
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
closeExplorerAfterOpen: true,
|
||||
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
||||
@@ -141,6 +141,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
||||
placement,
|
||||
}),
|
||||
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
|
||||
requestFileNavigation: useWorkspaceLayoutStore.getState().requestFileNavigation,
|
||||
});
|
||||
},
|
||||
[serverId, workspaceId],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -327,10 +328,30 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
],
|
||||
);
|
||||
|
||||
const allActions = useMemo(
|
||||
() => [
|
||||
...actions,
|
||||
{
|
||||
id: "workspace-content-search",
|
||||
group: "actions" as const,
|
||||
groupRank: 0,
|
||||
rank: 10,
|
||||
visibility: "query" as const,
|
||||
keywords: ["search", "find", "contents", "files", "text"],
|
||||
run: () => useKeyboardShortcutsStore.getState().setCommandCenterOpen(true, "content"),
|
||||
presentation: {
|
||||
kind: "action" as const,
|
||||
title: t("shell.commandCenter.contentTitle"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[actions, t],
|
||||
);
|
||||
useCommandCenterActions({
|
||||
sourceId: "workspace",
|
||||
enabled: Boolean(serverId && cwd),
|
||||
actions,
|
||||
actions: allActions,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
|
||||
import {
|
||||
@@ -119,6 +119,7 @@ export function CompactExplorerSidebarHost({
|
||||
[openTab],
|
||||
);
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const requestFileNavigation = useWorkspaceLayoutStore((state) => state.requestFileNavigation);
|
||||
|
||||
const handleOpenExplorer = useCallback(() => {
|
||||
if (!model?.workspaceRoot) {
|
||||
@@ -136,16 +137,24 @@ export function CompactExplorerSidebarHost({
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceFileFromExplorer({
|
||||
filePath,
|
||||
openWorkspaceFileInFocusedPane({
|
||||
location: { path: filePath },
|
||||
persistenceKey: model.persistenceKey,
|
||||
closeExplorerAfterOpen: presentation === "overlay",
|
||||
showMobileAgent,
|
||||
openWorkspaceTabInFocusedPane,
|
||||
focusWorkspaceTab,
|
||||
requestFileNavigation,
|
||||
});
|
||||
},
|
||||
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
|
||||
[
|
||||
focusWorkspaceTab,
|
||||
model,
|
||||
openWorkspaceTabInFocusedPane,
|
||||
presentation,
|
||||
requestFileNavigation,
|
||||
showMobileAgent,
|
||||
],
|
||||
);
|
||||
|
||||
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
|
||||
@@ -80,11 +80,8 @@ function QuestionOptionRow({
|
||||
);
|
||||
|
||||
const optionLabelStyle = useMemo(
|
||||
() => [
|
||||
styles.optionLabel,
|
||||
{ color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted },
|
||||
],
|
||||
[isSelected, theme.colors.foreground, theme.colors.foregroundMuted],
|
||||
() => [styles.optionLabel, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
const optionDescriptionStyle = useMemo(
|
||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||
@@ -99,11 +96,11 @@ function QuestionOptionRow({
|
||||
styles.selectionControl,
|
||||
multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio,
|
||||
{
|
||||
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundExtraMuted,
|
||||
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted,
|
||||
backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent",
|
||||
},
|
||||
],
|
||||
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundExtraMuted],
|
||||
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted],
|
||||
);
|
||||
const radioDotStyle = useMemo(
|
||||
() => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }],
|
||||
@@ -631,7 +628,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
questionText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionsWrap: {
|
||||
@@ -656,7 +653,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
questionNavText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
optionItem: {
|
||||
flexDirection: "row",
|
||||
@@ -680,7 +677,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionDescription: {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload, CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
|
||||
export interface WorkspaceDraftAgentRequest {
|
||||
workspaceId: string;
|
||||
config: AgentSessionConfig;
|
||||
text: string;
|
||||
clientMessageId: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: CreateAgentRequestMessage["attachments"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the workspace draft tab and by the new-workspace screen when it finishes creation
|
||||
* after the user has already navigated away and no draft tab will ever mount.
|
||||
*/
|
||||
export async function requestWorkspaceDraftAgent(
|
||||
client: DaemonClient,
|
||||
request: WorkspaceDraftAgentRequest,
|
||||
): Promise<AgentSnapshotPayload> {
|
||||
const images = await encodeImages(request.images);
|
||||
return await client.createAgent({
|
||||
config: request.config,
|
||||
workspaceId: request.workspaceId,
|
||||
clientMessageId: request.clientMessageId,
|
||||
...(request.text ? { initialPrompt: request.text } : {}),
|
||||
...(images && images.length > 0 ? { images } : {}),
|
||||
...(request.attachments && request.attachments.length > 0
|
||||
? { attachments: request.attachments }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
|
||||
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import {
|
||||
@@ -194,14 +194,15 @@ async function submitDraftCreateRequest(input: {
|
||||
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
|
||||
});
|
||||
|
||||
const imagesData = await encodeImages(images);
|
||||
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
||||
const result = await requestWorkspaceDraftAgent(client, {
|
||||
const result = await client.createAgent({
|
||||
config,
|
||||
workspaceId,
|
||||
text,
|
||||
...(text ? { initialPrompt: text } : {}),
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
...(images ? { images } : {}),
|
||||
...(attachmentsArray ? { attachments: attachmentsArray } : {}),
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -72,3 +72,9 @@ describe("resolveFilePreviewReadTarget", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves literal whitespace for a source read target", () => {
|
||||
expect(
|
||||
resolveFilePreviewReadTarget({ path: " leading.txt ", workspaceRoot: "/workspace " }),
|
||||
).toEqual({ cwd: "/workspace ", path: " leading.txt " });
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ export function resolveFilePreviewReadTarget(input: {
|
||||
path: string;
|
||||
workspaceRoot?: string;
|
||||
}): FilePreviewReadTarget | null {
|
||||
const previewPath = input.path.trim();
|
||||
const previewPath = input.path;
|
||||
if (!previewPath) {
|
||||
return null;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function resolveFilePreviewReadTarget(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceRoot = input.workspaceRoot?.trim();
|
||||
const workspaceRoot = input.workspaceRoot;
|
||||
if (!isAbsolutePath(previewPath)) {
|
||||
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
|
||||
return null;
|
||||
|
||||
@@ -32,3 +32,20 @@ describe("explorerFileFromReadResult", () => {
|
||||
expect(embedded.hasBom).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps SVG image identity and exposes valid UTF-8 source for occurrence navigation", () => {
|
||||
const file = {
|
||||
...textRead(new TextEncoder().encode("<svg>needle</svg>")),
|
||||
kind: "image" as const,
|
||||
mime: "image/svg+xml",
|
||||
path: "icon.svg",
|
||||
};
|
||||
expect(explorerFileFromReadResult(file)).toMatchObject({
|
||||
kind: "image",
|
||||
content: "<svg>needle</svg>",
|
||||
});
|
||||
expect(explorerFileFromReadResult({ ...file, bytes: new Uint8Array([255]) })).toMatchObject({
|
||||
kind: "image",
|
||||
content: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,21 @@ import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ExplorerFile } from "@/stores/session-store";
|
||||
|
||||
export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
|
||||
const isText = file.kind === "text";
|
||||
// SVG keeps its normal image presentation while exposing its UTF-8 source to line targets.
|
||||
const isText = file.kind === "text" || file.mime === "image/svg+xml";
|
||||
let content: string | undefined;
|
||||
if (isText) {
|
||||
try {
|
||||
content = new TextDecoder("utf-8", { fatal: true }).decode(file.bytes);
|
||||
} catch (error) {
|
||||
if (file.kind === "text") throw error;
|
||||
}
|
||||
}
|
||||
return {
|
||||
path: file.path,
|
||||
kind: file.kind,
|
||||
encoding: isText ? "utf-8" : "none",
|
||||
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
|
||||
content,
|
||||
hasBom: isText && hasUtf8Bom(file.bytes),
|
||||
mimeType: file.mime,
|
||||
size: file.size,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
|
||||
import type { FileEditorStatus } from "./editor/model";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
||||
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -115,12 +116,6 @@ export function FilePanelBar({
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
chrome: {
|
||||
flexShrink: 0,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language";
|
||||
import { searchKeymap } from "@codemirror/search";
|
||||
import {
|
||||
EditorView,
|
||||
drawSelection,
|
||||
@@ -42,7 +41,6 @@ export function editorBaseExtensions(onSave: () => void) {
|
||||
indentWithTab,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap,
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -63,6 +61,13 @@ export function editorTheme(theme: EditorVisualTheme) {
|
||||
fontFamily: theme.monoFont,
|
||||
lineHeight: "1.45",
|
||||
},
|
||||
".cm-panels": { backgroundColor: theme.background, color: theme.foreground },
|
||||
".cm-panels-top": { borderBottom: "none" },
|
||||
".cm-searchMatch": { backgroundColor: theme.selection },
|
||||
".cm-searchMatch-selected": {
|
||||
backgroundColor: theme.selection,
|
||||
outline: `1px solid ${theme.cursor}`,
|
||||
},
|
||||
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
|
||||
".cm-gutters": {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { FileFind, FileFindModel } from "../find/index.web";
|
||||
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import { getCM, vim } from "@replit/codemirror-vim";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { FileEditorModel } from "./model";
|
||||
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
|
||||
|
||||
@@ -38,6 +40,8 @@ export function FileEditorView({
|
||||
onCursorChange,
|
||||
onVimModeChange,
|
||||
}: FileEditorViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [find] = useState(() => new FileFindModel());
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
@@ -54,6 +58,7 @@ export function FileEditorView({
|
||||
doc: values.content,
|
||||
extensions: [
|
||||
vimCompartment.of(values.vimEnabled ? vim() : []),
|
||||
find.extension,
|
||||
...editorBaseExtensions(() => void values.model.save()),
|
||||
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
|
||||
wrappingCompartment.of(wrappingForFile(values.filename)),
|
||||
@@ -81,7 +86,7 @@ export function FileEditorView({
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [find]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
@@ -99,15 +104,12 @@ export function FileEditorView({
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !location.lineStart) return;
|
||||
const lineStart = Math.min(location.lineStart, view.state.doc.lines);
|
||||
const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines);
|
||||
const from = view.state.doc.line(lineStart).from;
|
||||
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
|
||||
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
|
||||
selection: { anchor: from, head: to },
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
}, [location.lineEnd, location.lineStart, navigationRevision]);
|
||||
}, [location, navigationRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
@@ -140,16 +142,34 @@ export function FileEditorView({
|
||||
return () => cm.off("vim-mode-change", handleModeChange);
|
||||
}, [onVimModeChange, vimEnabled]);
|
||||
|
||||
const changed = resolveWorkspaceFileSelection(
|
||||
snapshot.content.replace(/\r\n?/g, "\n"),
|
||||
location,
|
||||
).changed;
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
data-testid="file-source-editor"
|
||||
aria-label={`Source editor for ${filename}`}
|
||||
style={HOST_STYLE}
|
||||
/>
|
||||
<div style={FRAME_STYLE}>
|
||||
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
data-testid="file-source-editor"
|
||||
aria-label={`Source editor for ${filename}`}
|
||||
style={HOST_STYLE}
|
||||
/>
|
||||
<FileFind model={find} editor={viewRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const remoteUpdate = Annotation.define<boolean>();
|
||||
// The Find widget floats inside this frame, so it stays relative; the change notice is the
|
||||
// only row that shares the column with the editor host.
|
||||
const FRAME_STYLE = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
} as const;
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { View, type View as ViewInstance } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaneFind, type PaneFindHandle } from "@/pane-find";
|
||||
import { usePaneFocusIfAny } from "@/panels/pane-context";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { FileFindModel } from "./model.web";
|
||||
|
||||
export { FileFindModel } from "./model.web";
|
||||
|
||||
export function FileFind({
|
||||
model,
|
||||
editor,
|
||||
}: {
|
||||
model: FileFindModel;
|
||||
editor: RefObject<EditorView | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const widget = useRef<PaneFindHandle>(null);
|
||||
// A source view can also render outside any pane, such as the Command Center content
|
||||
// preview. Pane Find belongs to the pane, so there is nothing to open there.
|
||||
const pane = usePaneFocusIfAny();
|
||||
const active = useRetainedPanelActive();
|
||||
// RN Web hands the underlying DOM element to a View ref; the model measures it.
|
||||
const setWidgetNode = useCallback(
|
||||
(node: ViewInstance | null) => model.setWidgetNode(node as unknown as HTMLElement | null),
|
||||
[model],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!pane?.isInteractive || !active) return;
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === "f"
|
||||
) {
|
||||
event.preventDefault();
|
||||
model.open(editor.current);
|
||||
widget.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [active, editor, model, pane?.isInteractive]);
|
||||
|
||||
const replace = useMemo(
|
||||
() =>
|
||||
state.readOnly
|
||||
? undefined
|
||||
: {
|
||||
value: state.replacement,
|
||||
onChange: model.setReplacement,
|
||||
onReplace: model.replace,
|
||||
onReplaceAll: model.replaceAll,
|
||||
},
|
||||
[model, state.readOnly, state.replacement],
|
||||
);
|
||||
if (!pane || !state.open) return null;
|
||||
const total = `${state.total}${state.limited ? "+" : ""}`;
|
||||
let status = "";
|
||||
if (state.query) {
|
||||
if (state.total === 0) status = t("paneFind.noMatches");
|
||||
else if (state.current) status = t("paneFind.position", { current: state.current, total });
|
||||
else status = t("paneFind.total", { total });
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={[styles.overlay, state.placement === "top" ? styles.overlayTop : styles.overlayBottom]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View ref={setWidgetNode} style={styles.widget}>
|
||||
<PaneFind
|
||||
ref={widget}
|
||||
query={state.query}
|
||||
status={status}
|
||||
canNavigate={state.total > 0}
|
||||
onQueryChange={model.setSearch}
|
||||
onNext={model.next}
|
||||
onPrevious={model.previous}
|
||||
onClose={model.close}
|
||||
replace={replace}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// The widget floats over the content; the model flips the corner when it would
|
||||
// otherwise sit on top of the active match.
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
left: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
alignItems: "flex-end",
|
||||
zIndex: 1,
|
||||
},
|
||||
// Bounds the widget to the pane; PaneFind's own maxWidth resolves against this.
|
||||
widget: { maxWidth: "100%" },
|
||||
overlayTop: { top: theme.spacing[2] },
|
||||
overlayBottom: { bottom: theme.spacing[2] },
|
||||
}));
|
||||
@@ -0,0 +1,255 @@
|
||||
import { EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView, keymap, type Panel, type ViewUpdate } from "@codemirror/view";
|
||||
import {
|
||||
closeSearchPanel,
|
||||
findNext,
|
||||
findPrevious,
|
||||
getSearchQuery,
|
||||
openSearchPanel,
|
||||
replaceAll,
|
||||
replaceNext,
|
||||
search,
|
||||
searchKeymap,
|
||||
SearchQuery,
|
||||
setSearchQuery,
|
||||
} from "@codemirror/search";
|
||||
|
||||
const MATCH_STATUS_LIMIT = 10_000;
|
||||
|
||||
/** Which corner of the editor the floating Find widget occupies. */
|
||||
export type FindPlacement = "top" | "bottom";
|
||||
|
||||
/** CodeMirror owns query, matching, selection, replacement, and panel lifetime. */
|
||||
export class FileFindModel {
|
||||
private view: EditorView | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
private matches: Array<{ from: number; to: number }> = [];
|
||||
private widget: HTMLElement | null = null;
|
||||
private widgetResize: ResizeObserver | null = null;
|
||||
private snapshot = {
|
||||
open: false,
|
||||
placement: "top" as FindPlacement,
|
||||
query: "",
|
||||
replacement: "",
|
||||
current: 0,
|
||||
total: 0,
|
||||
limited: false,
|
||||
readOnly: true,
|
||||
};
|
||||
readonly subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
readonly getSnapshot = () => this.snapshot;
|
||||
|
||||
readonly extension: Extension = [
|
||||
// The widget floats over the editor as a sibling overlay, so this panel is an
|
||||
// inert placeholder that only reports whether Find is open. Hide the placeholder
|
||||
// itself, never the shared panel container — Go to line's dialog lives there too.
|
||||
EditorView.theme({ ".cm-panel.paseo-file-find": { display: "none" } }),
|
||||
// Reveal matches clear of the widget instead of underneath it.
|
||||
EditorView.scrollMargins.of((view) => {
|
||||
const clearance = this.clearance(view);
|
||||
if (!clearance) return null;
|
||||
return this.snapshot.placement === "top" ? { top: clearance } : { bottom: clearance };
|
||||
}),
|
||||
search({ literal: true, top: true, createPanel: (view) => this.createPanel(view) }),
|
||||
EditorState.transactionExtender.of((transaction) => {
|
||||
// CodeMirror seeds every search-opening command from the selection. A
|
||||
// single-line field cannot represent CR/LF, so keep the previous query.
|
||||
const multilineSeed = transaction.effects.some(
|
||||
(effect) => effect.is(setSearchQuery) && /[\r\n]/.test(effect.value.search),
|
||||
);
|
||||
if (!multilineSeed) return null;
|
||||
return { effects: setSearchQuery.of(getSearchQuery(transaction.startState)) };
|
||||
}),
|
||||
keymap.of(searchKeymap.filter((binding) => binding.key !== "Mod-f")),
|
||||
];
|
||||
|
||||
readonly open = (view = this.view) => {
|
||||
if (view) openSearchPanel(view);
|
||||
};
|
||||
readonly close = () => {
|
||||
if (this.view) {
|
||||
closeSearchPanel(this.view);
|
||||
this.view.focus();
|
||||
}
|
||||
};
|
||||
readonly next = () => {
|
||||
if (this.view) findNext(this.view);
|
||||
};
|
||||
readonly previous = () => {
|
||||
if (this.view) findPrevious(this.view);
|
||||
};
|
||||
readonly replace = () => {
|
||||
if (this.view) replaceNext(this.view);
|
||||
};
|
||||
readonly replaceAll = () => {
|
||||
if (this.view) replaceAll(this.view);
|
||||
};
|
||||
readonly setReplacement = (replacement: string) =>
|
||||
this.setQuery(this.snapshot.query, replacement);
|
||||
readonly setSearch = (query: string) => {
|
||||
this.setQuery(query, this.snapshot.replacement);
|
||||
if (this.view && this.matches.length > 0) {
|
||||
this.view.dispatch({ selection: { anchor: this.view.state.selection.main.from } });
|
||||
findNext(this.view);
|
||||
}
|
||||
};
|
||||
|
||||
private setQuery(query: string, replacement: string) {
|
||||
this.view?.dispatch({
|
||||
effects: setSearchQuery.of(
|
||||
new SearchQuery({ search: query, replace: replacement, literal: true }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The widget renders outside CodeMirror, so the editor cannot see it. Handing the
|
||||
* node here lets this model — which already owns reveal — measure the obstruction.
|
||||
*/
|
||||
readonly setWidgetNode = (node: HTMLElement | null) => {
|
||||
if (this.widget === node) return;
|
||||
this.widgetResize?.disconnect();
|
||||
this.widgetResize = null;
|
||||
this.widget = node;
|
||||
if (!node) return;
|
||||
this.widgetResize = new ResizeObserver(() => this.reposition());
|
||||
this.widgetResize.observe(node);
|
||||
this.reposition();
|
||||
};
|
||||
|
||||
private createPanel(view: EditorView): Panel {
|
||||
this.view = view;
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "paseo-file-find";
|
||||
return {
|
||||
dom,
|
||||
top: true,
|
||||
mount: () => {
|
||||
this.snapshot = { ...this.snapshot, open: true, placement: "top" };
|
||||
this.update(view, true);
|
||||
},
|
||||
update: (update: ViewUpdate) => {
|
||||
const queryChanged = !getSearchQuery(update.startState).eq(getSearchQuery(update.state));
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.selectionSet ||
|
||||
queryChanged ||
|
||||
update.geometryChanged ||
|
||||
update.viewportChanged
|
||||
)
|
||||
this.update(view, update.docChanged || queryChanged);
|
||||
},
|
||||
destroy: () => {
|
||||
this.snapshot = { ...this.snapshot, open: false, placement: "top" };
|
||||
this.publish();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Vertical space the widget takes out of the editor, including its inset. */
|
||||
private clearance(view: EditorView): number {
|
||||
const widget = this.widget;
|
||||
if (!widget || !this.snapshot.open) return 0;
|
||||
const box = widget.getBoundingClientRect();
|
||||
if (!box.height) return 0;
|
||||
const editor = view.scrollDOM.getBoundingClientRect();
|
||||
const clearance =
|
||||
this.snapshot.placement === "top" ? box.bottom - editor.top : editor.bottom - box.top;
|
||||
return Math.max(0, clearance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the widget to the opposite corner when it sits on top of the active match
|
||||
* and the editor has no scroll range left to reveal it. Placement only changes
|
||||
* when the current corner collides, so navigating matches never makes it bounce.
|
||||
*
|
||||
* Measurement runs through `requestMeasure` because CodeMirror refuses layout
|
||||
* reads while an update is in progress, and every caller here sits inside one.
|
||||
*/
|
||||
private reposition() {
|
||||
const view = this.view;
|
||||
if (!view || !this.widget || !this.snapshot.open) return;
|
||||
view.requestMeasure<FindPlacement>({
|
||||
key: this,
|
||||
read: () => this.measurePlacement(view),
|
||||
write: (placement) => {
|
||||
if (placement === this.snapshot.placement) return;
|
||||
this.snapshot = { ...this.snapshot, placement };
|
||||
this.publish();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private measurePlacement(view: EditorView): FindPlacement {
|
||||
const current = this.snapshot.placement;
|
||||
const widget = this.widget;
|
||||
if (!widget) return current;
|
||||
const selection = view.state.selection.main;
|
||||
if (selection.empty) return current;
|
||||
const from = view.coordsAtPos(selection.from);
|
||||
const to = view.coordsAtPos(selection.to);
|
||||
if (!from || !to) return current;
|
||||
const clearance = this.clearance(view);
|
||||
if (!clearance) return current;
|
||||
const box = widget.getBoundingClientRect();
|
||||
const editor = view.scrollDOM.getBoundingClientRect();
|
||||
const match = {
|
||||
top: Math.min(from.top, to.top),
|
||||
bottom: Math.max(from.bottom, to.bottom),
|
||||
right: Math.max(from.right, to.right),
|
||||
};
|
||||
const hits = (placement: FindPlacement) => {
|
||||
if (match.right <= box.left) return false;
|
||||
return placement === "top"
|
||||
? match.top < editor.top + clearance && match.bottom > editor.top
|
||||
: match.bottom > editor.bottom - clearance && match.top < editor.bottom;
|
||||
};
|
||||
if (!hits(current)) return current;
|
||||
const other: FindPlacement = current === "top" ? "bottom" : "top";
|
||||
return hits(other) ? current : other;
|
||||
}
|
||||
|
||||
private update(view: EditorView, recount: boolean) {
|
||||
const query = getSearchQuery(view.state);
|
||||
let limited = this.snapshot.limited;
|
||||
if (recount) {
|
||||
this.matches = [];
|
||||
limited = false;
|
||||
if (query.valid) {
|
||||
const cursor = query.getCursor(view.state);
|
||||
for (let match = cursor.next(); !match.done; match = cursor.next()) {
|
||||
if (this.matches.length === MATCH_STATUS_LIMIT) {
|
||||
limited = true;
|
||||
break;
|
||||
}
|
||||
this.matches.push(match.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
const selection = view.state.selection.main;
|
||||
const current =
|
||||
this.matches.findIndex(
|
||||
(match) => match.from === selection.from && match.to === selection.to,
|
||||
) + 1;
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
query: query.search,
|
||||
replacement: query.replace,
|
||||
current,
|
||||
total: this.matches.length,
|
||||
limited,
|
||||
readOnly: view.state.readOnly,
|
||||
};
|
||||
this.publish();
|
||||
this.reposition();
|
||||
}
|
||||
|
||||
private publish() {
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,14 @@ import { FileMarkdownPreview } from "./markdown-preview";
|
||||
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 { WorkspaceFileSource } from "./source";
|
||||
import type { FileConflictAlertState } from "./conflict-alert";
|
||||
import type { LiveFileModel } from "./live-file/model";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -58,24 +59,6 @@ interface FilePreviewBodyProps {
|
||||
|
||||
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function ReadonlySource({
|
||||
preview,
|
||||
filename,
|
||||
@@ -87,32 +70,13 @@ function ReadonlySource({
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const { t } = useTranslation();
|
||||
const visualTheme = useMemo(
|
||||
() => ({
|
||||
colorScheme: theme.colorScheme,
|
||||
background: theme.colors.surface0,
|
||||
foreground: theme.colors.foreground,
|
||||
cursor: theme.colors.terminal.cursor,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
border: theme.colors.border,
|
||||
selection: theme.colors.terminal.selectionBackground,
|
||||
monoFont: theme.fontFamily.mono,
|
||||
codeFontSize: theme.fontSize.code,
|
||||
syntax: theme.colors.syntax,
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
return (
|
||||
<FileSourceView
|
||||
<WorkspaceFileSource
|
||||
content={preview.content ?? ""}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
size={preview.size}
|
||||
theme={visualTheme}
|
||||
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -122,7 +86,7 @@ function TooLargeSource({ size }: { size?: number }) {
|
||||
return (
|
||||
<View style={styles.centerState} testID="file-source-too-large">
|
||||
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
|
||||
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
|
||||
{size ? <Text style={styles.binaryMetaText}>{formatFileSize(size)}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -164,7 +128,7 @@ function FilePreviewBody({
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
if (preview.kind === "text" || (location.lineStart && preview.content !== undefined)) {
|
||||
if (renderKind === "html") {
|
||||
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
|
||||
return (
|
||||
@@ -214,7 +178,7 @@ function FilePreviewBody({
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize(preview.size)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -239,8 +203,8 @@ export function FilePane({
|
||||
const supportsEditing = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||
const normalizedWorkspaceRoot = workspaceRoot;
|
||||
const normalizedFilePath = location.path || null;
|
||||
const readTarget = useMemo(
|
||||
() =>
|
||||
normalizedFilePath
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { FileSourceView } from "./view";
|
||||
|
||||
/** Read-only source preview. The source module owns platform rendering and its theme. */
|
||||
export function WorkspaceFileSource(props: {
|
||||
content: string;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
size: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const visualTheme = useMemo(
|
||||
() => ({
|
||||
colorScheme: theme.colorScheme,
|
||||
background: theme.colors.surface0,
|
||||
foreground: theme.colors.foreground,
|
||||
cursor: theme.colors.terminal.cursor,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
border: theme.colors.border,
|
||||
selection: theme.colors.terminal.selectionBackground,
|
||||
monoFont: theme.fontFamily.mono,
|
||||
codeFontSize: theme.fontSize.code,
|
||||
syntax: theme.colors.syntax,
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
return (
|
||||
<FileSourceView
|
||||
{...props}
|
||||
theme={visualTheme}
|
||||
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, Text, View, type ListRenderItem } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
@@ -58,14 +59,16 @@ function VirtualizedSource({
|
||||
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
|
||||
presentation: "highlighted" | "plain";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const listRef = useRef<FlatList<SourceLine>>(null);
|
||||
const lines = useMemo(() => {
|
||||
const source = content.replace(/\r\n?/g, "\n");
|
||||
if (presentation === "highlighted")
|
||||
return highlightCode(content, filename).map((tokens, index) => ({
|
||||
return highlightCode(source, filename).map((tokens, index) => ({
|
||||
number: index + 1,
|
||||
tokens,
|
||||
}));
|
||||
return content
|
||||
return source
|
||||
.split("\n")
|
||||
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
|
||||
}, [content, filename, presentation]);
|
||||
@@ -77,29 +80,63 @@ function VirtualizedSource({
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, [lines.length, location.lineStart, navigationRevision]);
|
||||
const selectedLine = lines[(location.lineStart ?? 1) - 1]?.tokens
|
||||
.map((token) => token.text)
|
||||
.join("");
|
||||
const changed =
|
||||
location.expectedText !== undefined &&
|
||||
selectedLine?.slice((location.columnStart ?? 1) - 1, (location.columnEnd ?? 1) - 1) !==
|
||||
location.expectedText;
|
||||
const renderLine = useCallback(
|
||||
({ item }: { item: SourceLine }) => (
|
||||
<SourceLineView
|
||||
line={item}
|
||||
start={!changed && item.number === location.lineStart ? location.columnStart : undefined}
|
||||
end={!changed && item.number === location.lineStart ? location.columnEnd : undefined}
|
||||
/>
|
||||
),
|
||||
[changed, location],
|
||||
);
|
||||
return (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={lines}
|
||||
keyExtractor={sourceLineKey}
|
||||
initialNumToRender={24}
|
||||
windowSize={9}
|
||||
getItemLayout={sourceLineLayout}
|
||||
renderItem={renderSourceLine}
|
||||
/>
|
||||
<View style={styles.root}>
|
||||
{changed ? (
|
||||
<Text accessibilityRole="alert" style={styles.notice}>
|
||||
{t("shell.commandCenter.contentChanged")}
|
||||
</Text>
|
||||
) : null}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={lines}
|
||||
keyExtractor={sourceLineKey}
|
||||
initialNumToRender={24}
|
||||
windowSize={9}
|
||||
getItemLayout={sourceLineLayout}
|
||||
extraData={location}
|
||||
renderItem={renderLine}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceLineView({ line }: { line: SourceLine }) {
|
||||
function SourceLineView({ line, start, end }: { line: SourceLine; start?: number; end?: number }) {
|
||||
let offset = 0;
|
||||
return (
|
||||
<View style={styles.line}>
|
||||
<Text style={styles.gutter}>{line.number}</Text>
|
||||
<Text selectable style={styles.text}>
|
||||
{line.tokens.map((token) => (
|
||||
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
{line.tokens.map((token) => {
|
||||
const tokenStart = offset;
|
||||
offset += token.text.length;
|
||||
const from = Math.max(0, Math.min(token.text.length, (start ?? 1) - 1 - tokenStart));
|
||||
const to = Math.max(from, Math.min(token.text.length, (end ?? 1) - 1 - tokenStart));
|
||||
return (
|
||||
<Text key={`${tokenStart}:${token.style}`} style={syntaxTokenStyleFor(token.style)}>
|
||||
{token.text.slice(0, from)}
|
||||
<Text style={styles.match}>{token.text.slice(from, to)}</Text>
|
||||
{token.text.slice(to)}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -113,9 +150,14 @@ function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index
|
||||
return { length: 20, offset: index * 20, index };
|
||||
}
|
||||
|
||||
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flex: 1 },
|
||||
notice: {
|
||||
padding: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
match: { backgroundColor: theme.colors.terminal.selectionBackground },
|
||||
unsupported: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FileFind, FileFindModel } from "../find/index.web";
|
||||
import { Compartment, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { EditorView, drawSelection } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { editorTheme } from "../editor/extensions.web";
|
||||
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
|
||||
@@ -59,6 +61,8 @@ function ReadonlyCodeMirror({
|
||||
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
|
||||
presentation: Exclude<SourcePresentation, "unsupported">;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [find] = useState(() => new FileFindModel());
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const initial = useRef({ content, filename, presentation, theme });
|
||||
@@ -71,7 +75,15 @@ function ReadonlyCodeMirror({
|
||||
state: EditorState.create({
|
||||
doc: values.content,
|
||||
extensions: [
|
||||
find.extension,
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.contentAttributes.of({
|
||||
tabindex: "0",
|
||||
"aria-label": `Source for ${values.filename}`,
|
||||
}),
|
||||
// The preview is never focused, so the browser draws no selection of its own; the
|
||||
// exact occurrence has to be painted by CodeMirror.
|
||||
drawSelection(),
|
||||
EditorView.editable.of(false),
|
||||
languageCompartment.of(
|
||||
languageFor({ filename: values.filename, presentation: values.presentation }),
|
||||
@@ -85,7 +97,7 @@ function ReadonlyCodeMirror({
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [find]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
@@ -105,12 +117,22 @@ function ReadonlyCodeMirror({
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !location.lineStart) return;
|
||||
const line = Math.min(location.lineStart, view.state.doc.lines);
|
||||
const from = view.state.doc.line(line).from;
|
||||
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
|
||||
}, [location.lineStart, navigationRevision]);
|
||||
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: to },
|
||||
// A match far into a long line lands at the scroller edge without the horizontal centre.
|
||||
effects: EditorView.scrollIntoView(from, { x: "center", y: "center" }),
|
||||
});
|
||||
}, [content, location, navigationRevision]);
|
||||
|
||||
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
|
||||
const changed = resolveWorkspaceFileSelection(content.replace(/\r\n?/g, "\n"), location).changed;
|
||||
return (
|
||||
<div style={FRAME_STYLE}>
|
||||
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
|
||||
<div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />
|
||||
<FileFind model={find} editor={viewRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function languageFor(input: {
|
||||
@@ -122,6 +144,16 @@ function languageFor(input: {
|
||||
: [];
|
||||
}
|
||||
|
||||
// The Find widget floats inside this frame, so it stays relative; the change notice is the
|
||||
// only row that shares the column with the editor host.
|
||||
const FRAME_STYLE = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
} as const;
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
const UNSUPPORTED_STYLE = {
|
||||
alignItems: "center",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ar: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "بحث",
|
||||
placeholder: "بحث في اللوحة",
|
||||
close: "إغلاق البحث",
|
||||
matches: "نتائج البحث",
|
||||
previous: "التطابق السابق",
|
||||
next: "التطابق التالي",
|
||||
toggleReplace: "إظهار الاستبدال",
|
||||
replaceWith: "استبدال بـ",
|
||||
replace: "استبدال",
|
||||
replaceAll: "استبدال الكل",
|
||||
noMatches: "لا توجد تطابقات",
|
||||
position: "{{current}} من {{total}}",
|
||||
total: "{{total}} تطابقات",
|
||||
},
|
||||
common: {
|
||||
back: "خلف",
|
||||
loading: "تحميل...",
|
||||
@@ -58,6 +73,18 @@ export const ar: TranslationResources = {
|
||||
close: "إغلاق القائمة",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "البحث في محتوى الملفات",
|
||||
content: "محتوى الملفات",
|
||||
contentPlaceholder: "البحث في الملفات المحفوظة...",
|
||||
contentUnavailable: "مساحة العمل غير متاحة",
|
||||
contentUpdateHost: "حدّث هذا المضيف للبحث في محتوى الملفات",
|
||||
contentBack: "العودة إلى النتائج",
|
||||
contentOpen: "فتح",
|
||||
contentLoadingPreview: "جارٍ تحميل المعاينة...",
|
||||
contentLimited: "النتائج محدودة — حدّد البحث أكثر",
|
||||
contentSkippedLargeFiles: "لم يتم البحث في الملفات الأكبر من {{size}}",
|
||||
contentChanged: "تغيّر الملف — لم تعد المطابقة في هذا الموضع",
|
||||
|
||||
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
|
||||
filePlaceholder: "البحث في الملفات...",
|
||||
searchingFiles: "جارٍ البحث في الملفات...",
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
export const en = {
|
||||
paneFind: {
|
||||
title: "Find",
|
||||
placeholder: "Find in pane",
|
||||
close: "Close Find",
|
||||
matches: "Find matches",
|
||||
previous: "Previous match",
|
||||
next: "Next match",
|
||||
toggleReplace: "Toggle replace",
|
||||
replaceWith: "Replace with",
|
||||
replace: "Replace",
|
||||
replaceAll: "Replace all",
|
||||
noMatches: "No matches",
|
||||
position: "{{current}} of {{total}}",
|
||||
total: "{{total}} matches",
|
||||
},
|
||||
common: {
|
||||
back: "Back",
|
||||
loading: "Loading...",
|
||||
@@ -55,6 +70,18 @@ export const en = {
|
||||
close: "Close menu",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Search file contents",
|
||||
content: "File contents",
|
||||
contentPlaceholder: "Search saved file contents...",
|
||||
contentUnavailable: "Workspace unavailable",
|
||||
contentUpdateHost: "Update this host to search file contents",
|
||||
contentBack: "Back to results",
|
||||
contentOpen: "Open",
|
||||
contentLoadingPreview: "Loading preview...",
|
||||
contentLimited: "Results limited — refine your search",
|
||||
contentSkippedLargeFiles: "Files over {{size}} were not searched",
|
||||
contentChanged: "File changed — saved match is no longer at this location",
|
||||
|
||||
placeholder: "Search commands, files, workspaces, and agents...",
|
||||
filePlaceholder: "Search files...",
|
||||
searchingFiles: "Searching files...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const es: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "Buscar",
|
||||
placeholder: "Buscar en el panel",
|
||||
close: "Cerrar búsqueda",
|
||||
matches: "Coincidencias",
|
||||
previous: "Coincidencia anterior",
|
||||
next: "Siguiente coincidencia",
|
||||
toggleReplace: "Mostrar reemplazo",
|
||||
replaceWith: "Reemplazar con",
|
||||
replace: "Reemplazar",
|
||||
replaceAll: "Reemplazar todo",
|
||||
noMatches: "Sin coincidencias",
|
||||
position: "{{current}} de {{total}}",
|
||||
total: "{{total}} coincidencias",
|
||||
},
|
||||
common: {
|
||||
back: "Atrás",
|
||||
loading: "Cargando...",
|
||||
@@ -58,6 +73,18 @@ export const es: TranslationResources = {
|
||||
close: "Cerrar menú",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Buscar contenido de archivos",
|
||||
content: "Contenido de archivos",
|
||||
contentPlaceholder: "Buscar contenido guardado...",
|
||||
contentUnavailable: "Espacio de trabajo no disponible",
|
||||
contentUpdateHost: "Actualiza este host para buscar contenido",
|
||||
contentBack: "Volver a resultados",
|
||||
contentOpen: "Abrir",
|
||||
contentLoadingPreview: "Cargando vista previa...",
|
||||
contentLimited: "Resultados limitados — precisa la búsqueda",
|
||||
contentSkippedLargeFiles: "No se buscó en archivos de más de {{size}}",
|
||||
contentChanged: "El archivo cambió — la coincidencia ya no está aquí",
|
||||
|
||||
placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...",
|
||||
filePlaceholder: "Buscar archivos...",
|
||||
searchingFiles: "Buscando archivos...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const fr: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "Rechercher",
|
||||
placeholder: "Rechercher dans le panneau",
|
||||
close: "Fermer la recherche",
|
||||
matches: "Résultats de recherche",
|
||||
previous: "Résultat précédent",
|
||||
next: "Résultat suivant",
|
||||
toggleReplace: "Afficher le remplacement",
|
||||
replaceWith: "Remplacer par",
|
||||
replace: "Remplacer",
|
||||
replaceAll: "Tout remplacer",
|
||||
noMatches: "Aucun résultat",
|
||||
position: "{{current}} sur {{total}}",
|
||||
total: "{{total}} résultats",
|
||||
},
|
||||
common: {
|
||||
back: "Dos",
|
||||
loading: "Chargement...",
|
||||
@@ -59,6 +74,18 @@ export const fr: TranslationResources = {
|
||||
close: "Fermer le menu",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Rechercher dans les fichiers",
|
||||
content: "Contenu des fichiers",
|
||||
contentPlaceholder: "Rechercher dans les fichiers enregistrés...",
|
||||
contentUnavailable: "Espace de travail indisponible",
|
||||
contentUpdateHost: "Mettez cet hôte à jour pour rechercher dans les fichiers",
|
||||
contentBack: "Retour aux résultats",
|
||||
contentOpen: "Ouvrir",
|
||||
contentLoadingPreview: "Chargement de l’aperçu...",
|
||||
contentLimited: "Résultats limités — précisez la recherche",
|
||||
contentSkippedLargeFiles: "Les fichiers de plus de {{size}} n’ont pas été analysés",
|
||||
contentChanged: "Fichier modifié — la correspondance n’est plus à cet emplacement",
|
||||
|
||||
placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...",
|
||||
filePlaceholder: "Rechercher des fichiers...",
|
||||
searchingFiles: "Recherche de fichiers...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ja: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "検索",
|
||||
placeholder: "ペイン内を検索",
|
||||
close: "検索を閉じる",
|
||||
matches: "検索結果",
|
||||
previous: "前の一致",
|
||||
next: "次の一致",
|
||||
toggleReplace: "置換を切り替え",
|
||||
replaceWith: "置換後の文字列",
|
||||
replace: "置換",
|
||||
replaceAll: "すべて置換",
|
||||
noMatches: "一致なし",
|
||||
position: "{{current}} / {{total}}",
|
||||
total: "{{total}} 件の一致",
|
||||
},
|
||||
common: {
|
||||
back: "戻る",
|
||||
loading: "読み込み中...",
|
||||
@@ -58,6 +73,18 @@ export const ja: TranslationResources = {
|
||||
close: "メニューを閉じる",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "ファイル内容を検索",
|
||||
content: "ファイル内容",
|
||||
contentPlaceholder: "保存済みファイルの内容を検索...",
|
||||
contentUnavailable: "ワークスペースを利用できません",
|
||||
contentUpdateHost: "ファイル内容を検索するにはホストを更新してください",
|
||||
contentBack: "検索結果に戻る",
|
||||
contentOpen: "開く",
|
||||
contentLoadingPreview: "プレビューを読み込み中...",
|
||||
contentLimited: "結果が上限に達しました — 検索を絞り込んでください",
|
||||
contentSkippedLargeFiles: "{{size}} を超えるファイルは検索されていません",
|
||||
contentChanged: "ファイルが変更され、この位置に一致する内容はありません",
|
||||
|
||||
placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...",
|
||||
filePlaceholder: "ファイルを検索...",
|
||||
searchingFiles: "ファイルを検索中...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ko: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "찾기",
|
||||
placeholder: "패널에서 찾기",
|
||||
close: "찾기 닫기",
|
||||
matches: "검색 결과",
|
||||
previous: "이전 일치 항목",
|
||||
next: "다음 일치 항목",
|
||||
toggleReplace: "바꾸기 표시 전환",
|
||||
replaceWith: "바꿀 내용",
|
||||
replace: "바꾸기",
|
||||
replaceAll: "모두 바꾸기",
|
||||
noMatches: "일치 항목 없음",
|
||||
position: "{{current}} / {{total}}",
|
||||
total: "일치 항목 {{total}}개",
|
||||
},
|
||||
common: {
|
||||
back: "뒤로",
|
||||
loading: "불러오는 중...",
|
||||
@@ -58,6 +73,18 @@ export const ko: TranslationResources = {
|
||||
close: "메뉴 닫기",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "파일 내용 검색",
|
||||
content: "파일 내용",
|
||||
contentPlaceholder: "저장된 파일 내용 검색...",
|
||||
contentUnavailable: "작업 공간을 사용할 수 없음",
|
||||
contentUpdateHost: "파일 내용을 검색하려면 호스트를 업데이트하세요",
|
||||
contentBack: "결과로 돌아가기",
|
||||
contentOpen: "열기",
|
||||
contentLoadingPreview: "미리보기 로딩 중...",
|
||||
contentLimited: "결과 제한 — 검색 범위를 좁히세요",
|
||||
contentSkippedLargeFiles: "{{size}}보다 큰 파일은 검색하지 않았습니다",
|
||||
contentChanged: "파일이 변경되어 이 위치에 일치하는 내용이 없습니다",
|
||||
|
||||
placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...",
|
||||
filePlaceholder: "파일 검색...",
|
||||
searchingFiles: "파일 검색 중...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ptBR: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "Buscar",
|
||||
placeholder: "Buscar no painel",
|
||||
close: "Fechar busca",
|
||||
matches: "Correspondências",
|
||||
previous: "Correspondência anterior",
|
||||
next: "Próxima correspondência",
|
||||
toggleReplace: "Mostrar substituição",
|
||||
replaceWith: "Substituir por",
|
||||
replace: "Substituir",
|
||||
replaceAll: "Substituir tudo",
|
||||
noMatches: "Nenhuma correspondência",
|
||||
position: "{{current}} de {{total}}",
|
||||
total: "{{total}} correspondências",
|
||||
},
|
||||
common: {
|
||||
back: "Voltar",
|
||||
loading: "Carregando...",
|
||||
@@ -58,6 +73,18 @@ export const ptBR: TranslationResources = {
|
||||
close: "Fechar menu",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Buscar conteúdo dos arquivos",
|
||||
content: "Conteúdo dos arquivos",
|
||||
contentPlaceholder: "Buscar conteúdo salvo...",
|
||||
contentUnavailable: "Espaço de trabalho indisponível",
|
||||
contentUpdateHost: "Atualize este host para buscar conteúdo",
|
||||
contentBack: "Voltar aos resultados",
|
||||
contentOpen: "Abrir",
|
||||
contentLoadingPreview: "Carregando prévia...",
|
||||
contentLimited: "Resultados limitados — refine a busca",
|
||||
contentSkippedLargeFiles: "Arquivos maiores que {{size}} não foram pesquisados",
|
||||
contentChanged: "Arquivo alterado — a correspondência não está mais neste local",
|
||||
|
||||
placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...",
|
||||
filePlaceholder: "Pesquisar arquivos...",
|
||||
searchingFiles: "Pesquisando arquivos...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ru: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "Найти",
|
||||
placeholder: "Найти в панели",
|
||||
close: "Закрыть поиск",
|
||||
matches: "Совпадения",
|
||||
previous: "Предыдущее совпадение",
|
||||
next: "Следующее совпадение",
|
||||
toggleReplace: "Показать замену",
|
||||
replaceWith: "Заменить на",
|
||||
replace: "Заменить",
|
||||
replaceAll: "Заменить всё",
|
||||
noMatches: "Нет совпадений",
|
||||
position: "{{current}} из {{total}}",
|
||||
total: "Совпадений: {{total}}",
|
||||
},
|
||||
common: {
|
||||
back: "Назад",
|
||||
loading: "Загрузка...",
|
||||
@@ -58,6 +73,18 @@ export const ru: TranslationResources = {
|
||||
close: "Закрыть меню",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "Поиск по содержимому файлов",
|
||||
content: "Содержимое файлов",
|
||||
contentPlaceholder: "Поиск в сохранённых файлах...",
|
||||
contentUnavailable: "Рабочее пространство недоступно",
|
||||
contentUpdateHost: "Обновите этот хост для поиска по содержимому",
|
||||
contentBack: "Назад к результатам",
|
||||
contentOpen: "Открыть",
|
||||
contentLoadingPreview: "Загрузка предпросмотра...",
|
||||
contentLimited: "Результаты ограничены — уточните поиск",
|
||||
contentSkippedLargeFiles: "Файлы больше {{size}} не искались",
|
||||
contentChanged: "Файл изменён — совпадения больше нет в этом месте",
|
||||
|
||||
placeholder: "Поиск команд, файлов, рабочих пространств и агентов...",
|
||||
filePlaceholder: "Поиск файлов...",
|
||||
searchingFiles: "Поиск файлов...",
|
||||
|
||||
@@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const zhCN: TranslationResources = {
|
||||
paneFind: {
|
||||
title: "查找",
|
||||
placeholder: "在窗格中查找",
|
||||
close: "关闭查找",
|
||||
matches: "查找结果",
|
||||
previous: "上一个匹配项",
|
||||
next: "下一个匹配项",
|
||||
toggleReplace: "切换替换",
|
||||
replaceWith: "替换为",
|
||||
replace: "替换",
|
||||
replaceAll: "全部替换",
|
||||
noMatches: "无匹配项",
|
||||
position: "{{current}} / {{total}}",
|
||||
total: "{{total}} 个匹配项",
|
||||
},
|
||||
common: {
|
||||
back: "返回",
|
||||
loading: "加载中...",
|
||||
@@ -58,6 +73,18 @@ export const zhCN: TranslationResources = {
|
||||
close: "关闭菜单",
|
||||
},
|
||||
commandCenter: {
|
||||
contentTitle: "搜索文件内容",
|
||||
content: "文件内容",
|
||||
contentPlaceholder: "搜索已保存的文件内容...",
|
||||
contentUnavailable: "工作区不可用",
|
||||
contentUpdateHost: "请更新此主机以搜索文件内容",
|
||||
contentBack: "返回结果",
|
||||
contentOpen: "打开",
|
||||
contentLoadingPreview: "正在加载预览...",
|
||||
contentLimited: "结果已达上限 — 请缩小搜索范围",
|
||||
contentSkippedLargeFiles: "未搜索大于 {{size}} 的文件",
|
||||
contentChanged: "文件已更改 — 此位置不再匹配",
|
||||
|
||||
placeholder: "搜索命令、文件、工作区和 Agent...",
|
||||
filePlaceholder: "搜索文件...",
|
||||
searchingFiles: "正在搜索文件...",
|
||||
|
||||
@@ -46,6 +46,7 @@ export type KeyboardActionId =
|
||||
| "settings.toggle"
|
||||
| "command-center.toggle"
|
||||
| "command-center.files"
|
||||
| "command-center.content"
|
||||
| "shortcuts.dialog.toggle"
|
||||
| "workspace.terminal.new"
|
||||
| "workspace.new"
|
||||
|
||||
@@ -1196,3 +1196,20 @@ describe("direct new-tab target shortcuts", () => {
|
||||
).toEqual([["ctrl", "shift", "H"]]);
|
||||
});
|
||||
});
|
||||
|
||||
it("searches contents from terminal focus while preserving explicit Focus overrides", () => {
|
||||
const event = { key: "F", code: "KeyF", metaKey: true, shiftKey: true };
|
||||
const context = { isMac: true, isDesktop: true, focusScope: "terminal" as const };
|
||||
expect(resolveShortcut({ event, context }).match?.action).toBe("command-center.content");
|
||||
expect(resolveShortcut({ event: { ...event, altKey: true }, context }).match?.action).toBe(
|
||||
"view.toggle.focus",
|
||||
);
|
||||
const bindings = buildEffectiveBindings({ "view-toggle-focus-cmd-shift-f-mac": "Cmd+Shift+F" });
|
||||
expect(resolveShortcut({ event, context, bindings }).match?.action).toBe("view.toggle.focus");
|
||||
const disabled = buildEffectiveBindings({
|
||||
"view-toggle-focus-cmd-shift-f-mac": UNASSIGNED_COMBO,
|
||||
});
|
||||
expect(
|
||||
resolveShortcut({ event: { ...event, altKey: true }, context, bindings: disabled }).match,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -156,6 +156,7 @@ export const SHORTCUT_HELP_ROW_ORDER: Record<ShortcutSectionId, readonly string[
|
||||
general: [
|
||||
"toggle-command-center",
|
||||
"search-files",
|
||||
"search-workspace-content",
|
||||
"show-shortcuts",
|
||||
"toggle-settings",
|
||||
"cycle-theme",
|
||||
@@ -239,6 +240,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||
"toggle-right-sidebar": "settings.shortcuts.help.toggleRightSidebar",
|
||||
"toggle-both-sidebars": "settings.shortcuts.help.toggleBothSidebars",
|
||||
"toggle-settings": "settings.shortcuts.help.toggleSettings",
|
||||
"search-workspace-content": "shell.commandCenter.contentTitle",
|
||||
"toggle-focus": "settings.shortcuts.help.toggleFocusMode",
|
||||
"cycle-theme": "settings.shortcuts.help.cycleTheme",
|
||||
"focus-message-input": "settings.shortcuts.help.focusMessageInput",
|
||||
@@ -1003,7 +1005,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "view-toggle-focus-cmd-shift-f-mac",
|
||||
action: "view.toggle.focus",
|
||||
combo: "Cmd+Shift+F",
|
||||
combo: "Cmd+Alt+Shift+F",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "toggle-focus",
|
||||
@@ -1014,7 +1016,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "view-toggle-focus-ctrl-shift-f-non-mac",
|
||||
action: "view.toggle.focus",
|
||||
combo: "Ctrl+Shift+F",
|
||||
combo: "Ctrl+Alt+Shift+F",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "toggle-focus",
|
||||
@@ -1023,6 +1025,21 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "command-center-content-cmd-shift-f-mac",
|
||||
action: "command-center.content",
|
||||
combo: "Cmd+Shift+F",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: { id: "search-workspace-content", section: "general", label: "Search file contents" },
|
||||
},
|
||||
{
|
||||
id: "command-center-content-ctrl-shift-f-non-mac",
|
||||
action: "command-center.content",
|
||||
combo: "Ctrl+Shift+F",
|
||||
when: { mac: false, commandCenter: false },
|
||||
help: { id: "search-workspace-content", section: "general", label: "Search file contents" },
|
||||
},
|
||||
|
||||
// --- Theme cycling ---
|
||||
{
|
||||
id: "theme-cycle-cmd-shift-t-mac",
|
||||
|
||||
@@ -32,7 +32,7 @@ export type ShortcutAction =
|
||||
| { kind: "router-push"; route: string }
|
||||
| { kind: "open-project-picker" }
|
||||
| { kind: "callback"; name: ShortcutCallbackName }
|
||||
| { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" }
|
||||
| { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" | "content" }
|
||||
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
|
||||
|
||||
const NONE: ShortcutAction = { kind: "none" };
|
||||
@@ -209,6 +209,10 @@ export function routeKeyboardShortcut(
|
||||
return routeSettingsToggle(ctx);
|
||||
case "command-center.toggle":
|
||||
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
|
||||
case "command-center.content":
|
||||
if (parseHostWorkspaceRouteFromPathname(ctx.pathname))
|
||||
return { kind: "command-center-toggle", nextOpen: true, scope: "content" };
|
||||
return dispatch({ id: "workspace.project.pick", scope: "workspace" });
|
||||
case "command-center.files":
|
||||
if (parseHostWorkspaceRouteFromPathname(ctx.pathname)) {
|
||||
return { kind: "command-center-toggle", nextOpen: true, scope: "files" };
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type NativeSyntheticEvent,
|
||||
type TextInputKeyPressEventData,
|
||||
type TextInputProps,
|
||||
} from "react-native";
|
||||
import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import {
|
||||
createControlGeometry,
|
||||
resolveControlInteractionStyles,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import {
|
||||
mutedIconColorMapping,
|
||||
smallIconButtonChromeFrameSize,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
|
||||
const TextInput = withUnistyles(EditingTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ArrowUpIcon = withUnistyles(ArrowUp, mutedIconColorMapping);
|
||||
const ArrowDownIcon = withUnistyles(ArrowDown, mutedIconColorMapping);
|
||||
const CloseIcon = withUnistyles(X, mutedIconColorMapping);
|
||||
const ChevronDownIcon = withUnistyles(ChevronDown, mutedIconColorMapping);
|
||||
const ChevronRightIcon = withUnistyles(ChevronRight, mutedIconColorMapping);
|
||||
|
||||
export interface PaneFindHandle {
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
export interface PaneFindProps {
|
||||
query: string;
|
||||
status: string;
|
||||
canNavigate: boolean;
|
||||
onQueryChange(query: string): void;
|
||||
onNext(): void;
|
||||
onPrevious(): void;
|
||||
onClose(): void;
|
||||
replace?: {
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
onReplace(): void;
|
||||
onReplaceAll(): void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bordered box around one Find input. It owns the field chrome so the query
|
||||
* row and the replacement row land on the same rails, and so the match count can
|
||||
* sit inside the query box instead of widening the widget.
|
||||
*/
|
||||
const FindField = forwardRef<
|
||||
EditingTextInputHandle,
|
||||
{
|
||||
label: string;
|
||||
initialValue: string;
|
||||
height: number;
|
||||
trailing?: ReactNode;
|
||||
onChangeText(value: string): void;
|
||||
onKeyPress(event: NativeSyntheticEvent<TextInputKeyPressEventData>): void;
|
||||
autoFocus?: boolean;
|
||||
returnKeyType?: TextInputProps["returnKeyType"];
|
||||
}
|
||||
>(function FindField(
|
||||
{ label, initialValue, height, trailing, onChangeText, onKeyPress, autoFocus, returnKeyType },
|
||||
ref,
|
||||
) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const onFocus = useCallback(() => setFocused(true), []);
|
||||
const onBlur = useCallback(() => setFocused(false), []);
|
||||
const fieldStyle = useMemo(
|
||||
() => [
|
||||
styles.field,
|
||||
{ minHeight: height },
|
||||
resolveControlInteractionStyles(
|
||||
{
|
||||
controlRest: styles.controlRest,
|
||||
controlHover: styles.controlHover,
|
||||
controlActive: styles.controlActive,
|
||||
},
|
||||
{ focused },
|
||||
),
|
||||
],
|
||||
[focused, height],
|
||||
);
|
||||
return (
|
||||
<View style={fieldStyle}>
|
||||
<TextInput
|
||||
ref={ref}
|
||||
autoFocus={autoFocus}
|
||||
selectTextOnFocus
|
||||
initialValue={initialValue}
|
||||
onChangeText={onChangeText}
|
||||
onKeyPress={onKeyPress}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
accessibilityLabel={label}
|
||||
placeholder={label}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
blurOnSubmit={false}
|
||||
returnKeyType={returnKeyType}
|
||||
style={styles.input}
|
||||
/>
|
||||
{trailing}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
/** Pane-local chrome. The content owner supplies search state and commands. */
|
||||
export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneFind(
|
||||
{ query, status, canNavigate, onQueryChange, onNext, onPrevious, onClose, replace },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const controlSize = smallIconButtonChromeFrameSize(isCompact);
|
||||
const glyphSize = paneContentToolbarIconSize(isCompact);
|
||||
const rowHeight = Math.max(COLLAPSED_FIELD_HEIGHT, controlSize);
|
||||
// Keep the replace actions on the same row height as the icon controls beside them.
|
||||
const actionSize = isCompact ? "sm" : "xs";
|
||||
const input = useRef<EditingTextInputHandle>(null);
|
||||
const replacementInput = useRef<EditingTextInputHandle>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (input.current?.getText() !== query) input.current?.replaceText(query);
|
||||
}, [query]);
|
||||
useLayoutEffect(() => {
|
||||
if (replace && replacementInput.current?.getText() !== replace.value)
|
||||
replacementInput.current?.replaceText(replace.value);
|
||||
}, [replace]);
|
||||
const [replaceExpanded, setReplaceExpanded] = useState(false);
|
||||
const focus = useCallback(() => {
|
||||
input.current?.focus();
|
||||
const text = input.current?.getText() ?? "";
|
||||
input.current?.replaceText(text, { start: 0, end: text.length });
|
||||
}, []);
|
||||
useImperativeHandle(ref, () => ({ focus }), [focus]);
|
||||
|
||||
const onKeyPress = useCallback(
|
||||
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
const key = event.nativeEvent as TextInputKeyPressEventData & {
|
||||
shiftKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
};
|
||||
if (isImeComposingKeyboardEvent(key)) return;
|
||||
if (
|
||||
(key.metaKey || key.ctrlKey) &&
|
||||
!key.altKey &&
|
||||
!key.shiftKey &&
|
||||
key.key.toLowerCase() === "f"
|
||||
) {
|
||||
// RN Web inputs stop keydown before the pane's document listener.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
focus();
|
||||
} else if (key.key === "Escape" || key.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (key.key === "Escape") onClose();
|
||||
else if (key.shiftKey) onPrevious();
|
||||
else onNext();
|
||||
}
|
||||
},
|
||||
[focus, onClose, onNext, onPrevious],
|
||||
);
|
||||
const toggleReplace = useCallback(() => setReplaceExpanded((expanded) => !expanded), []);
|
||||
const gutterStyle = useMemo(
|
||||
() => [styles.gutter, { width: controlSize, height: rowHeight }],
|
||||
[controlSize, rowHeight],
|
||||
);
|
||||
const matchCount = useMemo(
|
||||
() => (
|
||||
<Text
|
||||
style={styles.status}
|
||||
role="status"
|
||||
accessibilityLabel={t("paneFind.matches")}
|
||||
accessibilityLiveRegion="polite"
|
||||
>
|
||||
{status}
|
||||
</Text>
|
||||
),
|
||||
[status, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.widget}
|
||||
accessibilityLabel={t("paneFind.title")}
|
||||
{...(isWeb
|
||||
? {
|
||||
onKeyDown: (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !isImeComposingKeyboardEvent(event.nativeEvent)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{replace ? (
|
||||
<View style={gutterStyle}>
|
||||
<ToolbarButton
|
||||
label={t("paneFind.toggleReplace")}
|
||||
aria-expanded={replaceExpanded}
|
||||
compact={isCompact}
|
||||
onPress={toggleReplace}
|
||||
>
|
||||
{replaceExpanded ? (
|
||||
<ChevronDownIcon size={glyphSize} />
|
||||
) : (
|
||||
<ChevronRightIcon size={glyphSize} />
|
||||
)}
|
||||
</ToolbarButton>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.rows}>
|
||||
<View style={styles.row}>
|
||||
<FindField
|
||||
ref={input}
|
||||
autoFocus
|
||||
label={t("paneFind.placeholder")}
|
||||
initialValue={query}
|
||||
height={rowHeight}
|
||||
returnKeyType="search"
|
||||
trailing={matchCount}
|
||||
onChangeText={onQueryChange}
|
||||
onKeyPress={onKeyPress}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label={t("paneFind.previous")}
|
||||
compact={isCompact}
|
||||
disabled={!canNavigate}
|
||||
onPress={onPrevious}
|
||||
>
|
||||
<ArrowUpIcon size={glyphSize} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t("paneFind.next")}
|
||||
compact={isCompact}
|
||||
disabled={!canNavigate}
|
||||
onPress={onNext}
|
||||
>
|
||||
<ArrowDownIcon size={glyphSize} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label={t("paneFind.close")} compact={isCompact} onPress={onClose}>
|
||||
<CloseIcon size={glyphSize} />
|
||||
</ToolbarButton>
|
||||
</View>
|
||||
{replace && replaceExpanded ? (
|
||||
<View style={styles.row}>
|
||||
<FindField
|
||||
ref={replacementInput}
|
||||
label={t("paneFind.replaceWith")}
|
||||
initialValue={replace.value}
|
||||
height={rowHeight}
|
||||
onChangeText={replace.onChange}
|
||||
onKeyPress={onKeyPress}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={actionSize}
|
||||
style={styles.replaceAction}
|
||||
disabled={!canNavigate}
|
||||
onPress={replace.onReplace}
|
||||
>
|
||||
{t("paneFind.replace")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={actionSize}
|
||||
style={styles.replaceAction}
|
||||
disabled={!canNavigate}
|
||||
onPress={replace.onReplaceAll}
|
||||
>
|
||||
{t("paneFind.replaceAll")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
/** Desktop find rows run one step tighter than a form field — this is pane chrome. */
|
||||
const COLLAPSED_FIELD_HEIGHT = 28;
|
||||
const FIND_WIDGET_WIDTH = 340;
|
||||
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
return {
|
||||
widget: {
|
||||
width: FIND_WIDGET_WIDTH,
|
||||
maxWidth: "100%",
|
||||
flexDirection: "row",
|
||||
padding: theme.spacing[1.5],
|
||||
gap: theme.spacing[1],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
// The disclosure column spans both rows so the two fields share a leading rail.
|
||||
gutter: { alignItems: "center", justifyContent: "center", flexShrink: 0 },
|
||||
rows: { flex: 1, minWidth: 0, gap: theme.spacing[1] },
|
||||
row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1] },
|
||||
field: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
controlRest: { ...geometry.controlRest },
|
||||
controlHover: { ...geometry.controlHover },
|
||||
controlActive: { ...geometry.controlActive },
|
||||
input: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
color: theme.colors.foreground,
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
...geometry.fieldTextSm,
|
||||
},
|
||||
status: {
|
||||
flexShrink: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
// Ghost actions sit on the field's rail, so they carry the field's padding.
|
||||
replaceAction: { paddingHorizontal: theme.spacing[2] },
|
||||
};
|
||||
});
|
||||
@@ -75,7 +75,12 @@ export function usePaneContext(): PaneContextValue {
|
||||
}
|
||||
|
||||
export function usePaneFocus(): PaneFocusContextValue {
|
||||
const value = useContext(PaneFocusContext);
|
||||
const value = usePaneFocusIfAny();
|
||||
invariant(value, "PaneFocusContext is required");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Pane focus for subtrees that a pane may or may not own, such as an overlay preview. */
|
||||
export function usePaneFocusIfAny(): PaneFocusContextValue | null {
|
||||
return useContext(PaneFocusContext);
|
||||
}
|
||||
|
||||
@@ -85,15 +85,10 @@ import { useProjectIcons } from "@/projects/icons";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import type { MessagePayload } from "@/composer/types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
CreatePaseoWorktreeInput,
|
||||
DaemonClient,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
||||
@@ -128,11 +123,6 @@ import {
|
||||
type ListTerminalsPayload,
|
||||
upsertCreatedTerminalPayload,
|
||||
} from "./workspace/terminals/state";
|
||||
import {
|
||||
captureWorkspaceDraftCleanup,
|
||||
createWorkspaceAgentInBackground,
|
||||
} from "./new-workspace/background-handoff";
|
||||
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
|
||||
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -757,19 +747,10 @@ function normalizeBranchDetails(
|
||||
return names.map((name) => ({ name, committerDate: 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* "background" means the user left the New workspace screen mid-creation, so nothing navigated
|
||||
* and the screen — if still mounted under another route — has to drop its pending state itself.
|
||||
*/
|
||||
type SubmitOutcome = "navigated" | "background";
|
||||
|
||||
interface SubmitDraftInput {
|
||||
clearConsumedDraft: () => void;
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
draftId?: string;
|
||||
draftContextScopeKey: string | null;
|
||||
initialSetup?: WorkspaceDraftTabSetup;
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
@@ -778,8 +759,6 @@ interface SubmitDraftInput {
|
||||
provider: AgentProvider;
|
||||
composerState: NewWorkspaceComposerState;
|
||||
supportsForgeSearch: boolean;
|
||||
resolveClient: () => DaemonClient;
|
||||
isStillOnCreateScreen: () => boolean;
|
||||
}
|
||||
|
||||
type NewWorkspaceComposerState = NonNullable<
|
||||
@@ -881,13 +860,9 @@ interface CreateChatAgentInput {
|
||||
withInitialAgent: boolean;
|
||||
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
draftId?: string;
|
||||
draftContextScopeKey: string | null;
|
||||
supportsForgeSearch: boolean;
|
||||
resolveClient: () => DaemonClient;
|
||||
isStillOnCreateScreen: () => boolean;
|
||||
labels: {
|
||||
composerStateRequired: string;
|
||||
selectModel: string;
|
||||
@@ -943,9 +918,8 @@ function buildComposerInitialValues(input: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
|
||||
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
|
||||
const clearConsumedDraft = captureWorkspaceDraftCleanup(input);
|
||||
const { text, attachments, cwd } = payload;
|
||||
if (!composerState) {
|
||||
throw new Error(input.labels.composerStateRequired);
|
||||
@@ -973,13 +947,10 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOu
|
||||
provider,
|
||||
composerState,
|
||||
});
|
||||
return await submitWorkspaceDraft({
|
||||
clearConsumedDraft,
|
||||
submitWorkspaceDraft({
|
||||
serverId,
|
||||
clearDraft,
|
||||
draftKey: input.draftKey,
|
||||
draftId: input.draftId,
|
||||
draftContextScopeKey: input.draftContextScopeKey,
|
||||
initialSetup,
|
||||
workspaceId: ensuredWorkspace.id,
|
||||
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
||||
@@ -988,8 +959,6 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOu
|
||||
provider,
|
||||
composerState,
|
||||
supportsForgeSearch: input.supportsForgeSearch,
|
||||
resolveClient: input.resolveClient,
|
||||
isStillOnCreateScreen: input.isStillOnCreateScreen,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1052,7 +1021,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
|
||||
};
|
||||
}
|
||||
|
||||
async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutcome> {
|
||||
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
const {
|
||||
serverId,
|
||||
clearDraft,
|
||||
@@ -1080,33 +1049,6 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutc
|
||||
composerState,
|
||||
initialSetup,
|
||||
});
|
||||
// Creation blocks on a slow daemon RPC. If the user moved on while it ran, the destination
|
||||
// screen's draft tab will never mount to issue create_agent, so this path does it instead.
|
||||
if (!input.isStillOnCreateScreen()) {
|
||||
await createWorkspaceAgentInBackground({
|
||||
clearConsumedDraft: input.clearConsumedDraft,
|
||||
createAgent: () =>
|
||||
requestWorkspaceDraftAgent(input.resolveClient(), {
|
||||
workspaceId,
|
||||
config: buildWorkspaceDraftAgentConfig({
|
||||
provider: submission.provider,
|
||||
cwd: submission.cwd,
|
||||
...(submission.modeId ? { modeId: submission.modeId } : {}),
|
||||
...(submission.model ? { model: submission.model } : {}),
|
||||
...(submission.thinkingOptionId
|
||||
? { thinkingOptionId: submission.thinkingOptionId }
|
||||
: {}),
|
||||
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
|
||||
}),
|
||||
text: text.trim(),
|
||||
clientMessageId,
|
||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
}),
|
||||
});
|
||||
return "background";
|
||||
}
|
||||
|
||||
useCreateFlowStore.getState().setPending({
|
||||
serverId,
|
||||
draftId,
|
||||
@@ -1140,7 +1082,6 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutc
|
||||
workspaceId,
|
||||
target: submission.target,
|
||||
});
|
||||
return "navigated";
|
||||
}
|
||||
|
||||
function useNewWorkspaceHostSelector(input: {
|
||||
@@ -1650,7 +1591,6 @@ export function NewWorkspaceScreen({
|
||||
const isolationPickerAnchorRef = useRef<View>(null);
|
||||
const hostPickerAnchorRef = useRef<View | null>(null);
|
||||
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
|
||||
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
|
||||
|
||||
// Launch target: what the composer submits to (chat agent, or a terminal
|
||||
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
|
||||
@@ -1756,12 +1696,11 @@ export function NewWorkspaceScreen({
|
||||
}, []);
|
||||
|
||||
const withConnectedClient = useCallback(() => {
|
||||
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
|
||||
if (!connectedClient?.isConnected) {
|
||||
if (!client || !isConnected) {
|
||||
throw new Error(t("newWorkspace.errors.hostDisconnected"));
|
||||
}
|
||||
return connectedClient;
|
||||
}, [selectedServerId, t]);
|
||||
return client;
|
||||
}, [client, isConnected, t]);
|
||||
|
||||
const clientReady = isConnected && Boolean(client);
|
||||
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
|
||||
@@ -2104,49 +2043,31 @@ export function NewWorkspaceScreen({
|
||||
await updateFormPreferences({ launchTarget });
|
||||
if (isEmptyWorkspaceSubmission(payload)) {
|
||||
setPendingAction("empty");
|
||||
let outcome: SubmitOutcome = "background";
|
||||
await runCreateEmptyWorkspace({
|
||||
payload,
|
||||
ensureWorkspace,
|
||||
serverId: selectedServerId,
|
||||
navigate: (targetServerId, workspaceId) => {
|
||||
if (!isStillOnCreateScreen()) {
|
||||
return;
|
||||
}
|
||||
outcome = "navigated";
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId });
|
||||
},
|
||||
navigate: (targetServerId, workspaceId) =>
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId }),
|
||||
});
|
||||
// Nothing navigated, so this screen may still be mounted under another route. Release
|
||||
// the pending lock it would otherwise keep forever.
|
||||
if (outcome === "background") {
|
||||
setPendingAction(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAction("chat");
|
||||
const outcome = await runCreateChatAgent({
|
||||
await runCreateChatAgent({
|
||||
payload,
|
||||
composerState,
|
||||
forkDraftSetup,
|
||||
ensureWorkspace,
|
||||
serverId: selectedServerId,
|
||||
clearDraft: chatDraft.clear,
|
||||
draftKey,
|
||||
draftId,
|
||||
draftContextScopeKey,
|
||||
supportsForgeSearch,
|
||||
resolveClient: withConnectedClient,
|
||||
isStillOnCreateScreen,
|
||||
labels: {
|
||||
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
|
||||
selectModel: t("newWorkspace.errors.selectModel"),
|
||||
},
|
||||
});
|
||||
if (outcome === "background") {
|
||||
setPendingAction(null);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setPendingAction(null);
|
||||
@@ -2156,20 +2077,16 @@ export function NewWorkspaceScreen({
|
||||
},
|
||||
[
|
||||
composerState,
|
||||
draftContextScopeKey,
|
||||
draftId,
|
||||
chatDraft.clear,
|
||||
draftKey,
|
||||
ensureWorkspace,
|
||||
forkDraftSetup,
|
||||
isStillOnCreateScreen,
|
||||
launchTarget,
|
||||
selectedServerId,
|
||||
supportsForgeSearch,
|
||||
t,
|
||||
toast,
|
||||
updateFormPreferences,
|
||||
withConnectedClient,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2178,7 +2095,6 @@ export function NewWorkspaceScreen({
|
||||
setErrorMessage(null);
|
||||
await updateFormPreferences({ launchTarget });
|
||||
setPendingAction("terminal");
|
||||
let outcome: SubmitOutcome = "background";
|
||||
await runCreateTerminalWorkspace({
|
||||
cwd: selectedSourceDirectory ?? "",
|
||||
prompt: terminalPromptText,
|
||||
@@ -2212,20 +2128,9 @@ export function NewWorkspaceScreen({
|
||||
withConnectedClient().sendTerminalInput(terminalId, { type: "input", data });
|
||||
},
|
||||
serverId: selectedServerId,
|
||||
// The terminal is spawned and fed its command before this runs, so skipping the
|
||||
// navigation costs nothing: it is standalone, and `reconcileTabs` auto-opens standalone
|
||||
// terminals when the workspace is next visited.
|
||||
navigate: (targetServerId, workspaceId, target) => {
|
||||
if (!isStillOnCreateScreen()) {
|
||||
return;
|
||||
}
|
||||
outcome = "navigated";
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId, target });
|
||||
},
|
||||
navigate: (targetServerId, workspaceId, target) =>
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId, target }),
|
||||
});
|
||||
if (outcome === "background") {
|
||||
setPendingAction(null);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setPendingAction(null);
|
||||
@@ -2234,7 +2139,6 @@ export function NewWorkspaceScreen({
|
||||
}
|
||||
}, [
|
||||
ensureWorkspace,
|
||||
isStillOnCreateScreen,
|
||||
launchTarget,
|
||||
queryClient,
|
||||
selectedServerId,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import {
|
||||
captureWorkspaceDraftCleanup,
|
||||
createWorkspaceAgentInBackground,
|
||||
} from "./background-handoff";
|
||||
|
||||
const DRAFT_ID = "draft_1";
|
||||
const DRAFT_KEY = "new-workspace";
|
||||
const SCOPE_KEY = "draft:draft_1";
|
||||
|
||||
function saveDraft(text: string) {
|
||||
useDraftStore
|
||||
.getState()
|
||||
.saveDraftInput({ draftKey: DRAFT_KEY, draft: { text, attachments: [] } });
|
||||
}
|
||||
|
||||
function draftText(): string | undefined {
|
||||
return useDraftStore.getState().drafts[DRAFT_KEY]?.input.text;
|
||||
}
|
||||
|
||||
function captureCleanup() {
|
||||
return captureWorkspaceDraftCleanup({
|
||||
draftId: DRAFT_ID,
|
||||
draftKey: DRAFT_KEY,
|
||||
clearDraft: (lifecycle) =>
|
||||
useDraftStore.getState().clearDraftInput({ draftKey: DRAFT_KEY, lifecycle }),
|
||||
draftContextScopeKey: SCOPE_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
function run(createAgent: () => Promise<unknown>, clearConsumedDraft = captureCleanup()) {
|
||||
return createWorkspaceAgentInBackground({ createAgent, clearConsumedDraft });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} });
|
||||
useCreateFlowStore.setState({ pendingByDraftId: {} });
|
||||
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } });
|
||||
saveDraft("do the thing");
|
||||
});
|
||||
|
||||
describe("createWorkspaceAgentInBackground", () => {
|
||||
it("preserves context added to a newer draft without editing its text", async () => {
|
||||
await run(async () => {
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId: DRAFT_ID,
|
||||
setup: {
|
||||
cwd: "/work/new-repo",
|
||||
provider: "codex",
|
||||
modeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe(
|
||||
"/work/new-repo",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates one agent and clears the consumed draft setup, attachments and input", async () => {
|
||||
let calls = 0;
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId: DRAFT_ID,
|
||||
setup: {
|
||||
cwd: "/work/repo",
|
||||
provider: "claude",
|
||||
modeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
});
|
||||
|
||||
await run(async () => {
|
||||
calls += 1;
|
||||
});
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId).toEqual({});
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toBeUndefined();
|
||||
expect(draftText()).toBe("");
|
||||
});
|
||||
|
||||
it("preserves context changed while workspace creation was pending", async () => {
|
||||
const clearConsumedDraft = captureCleanup();
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
await run(async () => undefined, clearConsumedDraft);
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
|
||||
expect(draftText()).toBe("do the thing");
|
||||
});
|
||||
|
||||
it("leaves no pending submission behind, so no draft tab can create a second agent", async () => {
|
||||
await run(async () => undefined);
|
||||
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().pendingByDraftId).toEqual({});
|
||||
expect(useCreateFlowStore.getState().pendingByDraftId).toEqual({});
|
||||
});
|
||||
|
||||
it("rethrows a failed creation and keeps the prompt for retry", async () => {
|
||||
const failure = new Error("daemon exploded");
|
||||
|
||||
await expect(
|
||||
run(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
).rejects.toThrow(failure);
|
||||
|
||||
expect(draftText()).toBe("do the thing");
|
||||
});
|
||||
|
||||
it("keeps a prompt typed into the shared draft while creation was in flight", async () => {
|
||||
await run(async () => {
|
||||
saveDraft("a different idea");
|
||||
});
|
||||
|
||||
expect(draftText()).toBe("a different idea");
|
||||
});
|
||||
it("keeps a newer draft written while workspace creation was pending", async () => {
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
const clearConsumedDraft = captureCleanup();
|
||||
saveDraft("a newer workspace idea");
|
||||
await run(async () => undefined, clearConsumedDraft);
|
||||
expect(draftText()).toBe("a newer workspace idea");
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
|
||||
interface WorkspaceDraftCleanupInput {
|
||||
draftId?: string;
|
||||
draftKey: string;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
draftContextScopeKey: string | null;
|
||||
}
|
||||
|
||||
/** Capture ownership before workspace creation starts, not when its response arrives. */
|
||||
export function captureWorkspaceDraftCleanup(input: WorkspaceDraftCleanupInput): () => void {
|
||||
const draftId = input.draftId?.trim() ?? "";
|
||||
const scopeKey = input.draftContextScopeKey ?? "";
|
||||
const draftVersion = useDraftStore.getState().drafts[input.draftKey]?.version;
|
||||
const setup = useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId];
|
||||
const attachments = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey];
|
||||
|
||||
return () => {
|
||||
// These stores update independently. A context-only edit does not bump the text version.
|
||||
// Any newer draft state owns the whole draft, including its unchanged context.
|
||||
if (
|
||||
useDraftStore.getState().drafts[input.draftKey]?.version !== draftVersion ||
|
||||
useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId] !== setup ||
|
||||
useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey] !== attachments
|
||||
) {
|
||||
return;
|
||||
}
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||
if (scopeKey) {
|
||||
useWorkspaceAttachmentsStore.getState().clearWorkspaceAttachments({ scopeKey });
|
||||
}
|
||||
input.clearDraft("sent");
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace draft tab normally issues create_agent once it mounts on the destination screen.
|
||||
* When the user leaves the New workspace screen before creation resolves, that tab never mounts,
|
||||
* so this path issues the request itself. The agent tab opens on its own when the workspace is
|
||||
* next visited — `reconcileTabs` auto-opens agents.
|
||||
*
|
||||
* The caller must not write a pending entry to the draft-submission or create-flow stores on this
|
||||
* path: those drive the draft tab's auto-submit, and the daemon does not dedupe create_agent by
|
||||
* clientMessageId, so a leftover entry would create a second agent when the workspace is opened.
|
||||
*/
|
||||
export async function createWorkspaceAgentInBackground(input: {
|
||||
clearConsumedDraft: () => void;
|
||||
createAgent: () => Promise<unknown>;
|
||||
}): Promise<void> {
|
||||
await input.createAgent();
|
||||
input.clearConsumedDraft();
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isNewWorkspaceScreenActive } from "./screen-presence";
|
||||
|
||||
describe("isNewWorkspaceScreenActive", () => {
|
||||
it("is active while the screen is mounted and the route is /new", () => {
|
||||
expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/new" })).toBe(true);
|
||||
});
|
||||
|
||||
it("is inactive once the screen has been popped off the stack", () => {
|
||||
expect(isNewWorkspaceScreenActive({ isMounted: false, pathname: "/new" })).toBe(false);
|
||||
});
|
||||
|
||||
it("is inactive while another route sits on top of the screen", () => {
|
||||
expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/settings" })).toBe(false);
|
||||
});
|
||||
|
||||
it("is inactive on a workspace route", () => {
|
||||
expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/host/workspace/wks_1" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { usePathname } from "expo-router";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
const NEW_WORKSPACE_PATHNAME = "/new";
|
||||
|
||||
export interface NewWorkspaceScreenPresence {
|
||||
isMounted: boolean;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace creation blocks on a slow daemon RPC, so by the time it resolves the user may have
|
||||
* moved on. Both signals are needed: popping to a workspace unmounts the screen while its last
|
||||
* observed pathname stays "/new", and pushing a route on top of it keeps it mounted underneath.
|
||||
*/
|
||||
export function isNewWorkspaceScreenActive(input: NewWorkspaceScreenPresence): boolean {
|
||||
return input.isMounted && input.pathname === NEW_WORKSPACE_PATHNAME;
|
||||
}
|
||||
|
||||
export function useNewWorkspaceScreenPresence(): () => boolean {
|
||||
const pathname = usePathname();
|
||||
const pathnameRef = useRef(pathname);
|
||||
pathnameRef.current = pathname;
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback(
|
||||
() =>
|
||||
isNewWorkspaceScreenActive({
|
||||
isMounted: isMountedRef.current,
|
||||
pathname: pathnameRef.current,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
|
||||
function createInput(closeExplorerAfterOpen: boolean) {
|
||||
return {
|
||||
filePath: "src/app.tsx",
|
||||
location: { path: "src/app.tsx" } as WorkspaceFileLocation,
|
||||
persistenceKey: "server:workspace",
|
||||
closeExplorerAfterOpen,
|
||||
showMobileAgent: vi.fn(),
|
||||
openWorkspaceTabInFocusedPane: vi.fn(() => "file-tab"),
|
||||
focusWorkspaceTab: vi.fn(),
|
||||
requestFileNavigation: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("openWorkspaceFileFromExplorer", () => {
|
||||
describe("openWorkspaceFileInFocusedPane", () => {
|
||||
it("closes the phone overlay after opening a file", () => {
|
||||
const input = createInput(true);
|
||||
|
||||
openWorkspaceFileFromExplorer(input);
|
||||
openWorkspaceFileInFocusedPane(input);
|
||||
|
||||
expect(input.showMobileAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -24,10 +26,29 @@ describe("openWorkspaceFileFromExplorer", () => {
|
||||
it("keeps the tablet dock open after opening a file", () => {
|
||||
const input = createInput(false);
|
||||
|
||||
openWorkspaceFileFromExplorer(input);
|
||||
openWorkspaceFileInFocusedPane(input);
|
||||
|
||||
expect(input.showMobileAgent).not.toHaveBeenCalled();
|
||||
expect(input.openWorkspaceTabInFocusedPane).toHaveBeenCalledOnce();
|
||||
expect(input.focusWorkspaceTab).toHaveBeenCalledWith("server:workspace", "file-tab");
|
||||
});
|
||||
|
||||
it("asks for navigation every time, so reopening the same occurrence recentres its pane", () => {
|
||||
const input = createInput(false);
|
||||
input.location = { path: "src/app.tsx", lineStart: 1201, columnStart: 17 };
|
||||
|
||||
openWorkspaceFileInFocusedPane(input);
|
||||
openWorkspaceFileInFocusedPane(input);
|
||||
|
||||
expect(input.requestFileNavigation).toHaveBeenCalledTimes(2);
|
||||
expect(input.requestFileNavigation).toHaveBeenLastCalledWith("file-tab");
|
||||
});
|
||||
|
||||
it("does not navigate when no tab could be opened", () => {
|
||||
const input = { ...createInput(false), openWorkspaceTabInFocusedPane: vi.fn(() => null) };
|
||||
|
||||
openWorkspaceFileInFocusedPane(input);
|
||||
|
||||
expect(input.requestFileNavigation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
type WorkspaceFileLocation,
|
||||
} from "@/workspace/file-open";
|
||||
import {
|
||||
FOCUSED_PANE_PLACEMENT,
|
||||
@@ -8,8 +9,8 @@ import {
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
interface OpenWorkspaceFileFromExplorerInput {
|
||||
filePath: string;
|
||||
interface OpenWorkspaceFileInput {
|
||||
location: WorkspaceFileLocation;
|
||||
persistenceKey: string | null;
|
||||
closeExplorerAfterOpen: boolean;
|
||||
showMobileAgent: () => void;
|
||||
@@ -19,16 +20,24 @@ interface OpenWorkspaceFileFromExplorerInput {
|
||||
placement?: WorkspaceTabPlacement,
|
||||
) => string | null;
|
||||
focusWorkspaceTab: (workspaceKey: string, tabId: string) => void;
|
||||
requestFileNavigation: (tabId: string) => void;
|
||||
}
|
||||
|
||||
export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplorerInput): void {
|
||||
/**
|
||||
* Opens a workspace file in the focused pane and navigates to it.
|
||||
*
|
||||
* The navigation request is the part callers keep forgetting: a file target is identity-stable, so
|
||||
* reopening the same path — or the same occurrence — reuses its tab and would otherwise leave the
|
||||
* pane wherever the reader had scrolled it. Every explicit activation asks for a reveal.
|
||||
*/
|
||||
export function openWorkspaceFileInFocusedPane(input: OpenWorkspaceFileInput): void {
|
||||
if (input.closeExplorerAfterOpen) {
|
||||
input.showMobileAgent();
|
||||
}
|
||||
if (!input.persistenceKey) {
|
||||
return;
|
||||
}
|
||||
const location = normalizeWorkspaceFileLocation({ path: input.filePath });
|
||||
const location = normalizeWorkspaceFileLocation(input.location);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
@@ -37,7 +46,9 @@ export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplor
|
||||
createWorkspaceFileTabTarget(location),
|
||||
FOCUSED_PANE_PLACEMENT,
|
||||
);
|
||||
if (tabId) {
|
||||
input.focusWorkspaceTab(input.persistenceKey, tabId);
|
||||
if (!tabId) {
|
||||
return;
|
||||
}
|
||||
input.focusWorkspaceTab(input.persistenceKey, tabId);
|
||||
input.requestFileNavigation(tabId);
|
||||
}
|
||||
|
||||
@@ -1647,17 +1647,13 @@ function WorkspaceScreenContent({
|
||||
) => openTab({ workspaceKey, target, intent: "reveal", parentTabId, placement }),
|
||||
[openTab],
|
||||
);
|
||||
// File targets stay identity-stable so the same path reuses its tab. Keep navigation
|
||||
// requests separate so clicking an unchanged path:line can still recenter the pane.
|
||||
const [fileNavigationRevisionByTabId, setFileNavigationRevisionByTabId] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const requestFileNavigation = useCallback((tabId: string) => {
|
||||
setFileNavigationRevisionByTabId((current) => ({
|
||||
...current,
|
||||
[tabId]: (current[tabId] ?? 0) + 1,
|
||||
}));
|
||||
}, []);
|
||||
// File targets stay identity-stable so the same path reuses its tab. Navigation requests live
|
||||
// with the tabs in the layout store, so an opener outside this screen — the Command Center —
|
||||
// asks for the same reveal through the same capability.
|
||||
const fileNavigationRevisionByTabId = useWorkspaceLayoutStore(
|
||||
(state) => state.fileNavigationRevisionByTabId,
|
||||
);
|
||||
const requestFileNavigation = useWorkspaceLayoutStore((state) => state.requestFileNavigation);
|
||||
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
|
||||
|
||||
const SHORTCUT_BADGE_DELAY_MS = 150;
|
||||
|
||||
export type CommandCenterScope = "files" | null;
|
||||
export type CommandCenterScope = "files" | "content" | null;
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
commandCenterOpen: boolean;
|
||||
|
||||
@@ -110,6 +110,12 @@ interface WorkspaceLayoutStore {
|
||||
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
||||
/**
|
||||
* Bumped whenever a file tab is explicitly activated. File targets are identity-stable so the
|
||||
* same path reuses its tab; without this an activation of an unchanged location would look like
|
||||
* a no-op and leave the pane wherever the reader had scrolled it.
|
||||
*/
|
||||
fileNavigationRevisionByTabId: Record<string, number>;
|
||||
explorerSidebarPaneIdByWorkspace: Record<string, string | null>;
|
||||
sidePaneIdByWorkspace: Record<string, string | null>;
|
||||
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
||||
@@ -120,6 +126,7 @@ interface WorkspaceLayoutStore {
|
||||
ensureSidePane: (workspaceKey: string) => string | null;
|
||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||
focusTab: (workspaceKey: string, tabId: string) => void;
|
||||
requestFileNavigation: (tabId: string) => void;
|
||||
selectTabInPane: (workspaceKey: string, paneId: string, tabId: string) => void;
|
||||
replaceTab: (
|
||||
workspaceKey: string,
|
||||
@@ -207,6 +214,9 @@ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
|
||||
path: z.string(),
|
||||
lineStart: z.number().int().positive().optional(),
|
||||
lineEnd: z.number().int().positive().optional(),
|
||||
columnStart: z.number().int().positive().optional(),
|
||||
columnEnd: z.number().int().positive().optional(),
|
||||
expectedText: z.string().optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("working_diff"),
|
||||
@@ -767,6 +777,7 @@ export function createWorkspaceLayoutStore(
|
||||
pendingAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
fileNavigationRevisionByTabId: {},
|
||||
explorerSidebarPaneIdByWorkspace: {},
|
||||
sidePaneIdByWorkspace: {},
|
||||
openTab: (input) => {
|
||||
@@ -1040,6 +1051,16 @@ export function createWorkspaceLayoutStore(
|
||||
};
|
||||
});
|
||||
},
|
||||
requestFileNavigation: (tabId) => {
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!normalizedTabId) return;
|
||||
set((state) => ({
|
||||
fileNavigationRevisionByTabId: {
|
||||
...state.fileNavigationRevisionByTabId,
|
||||
[normalizedTabId]: (state.fileNavigationRevisionByTabId[normalizedTabId] ?? 0) + 1,
|
||||
},
|
||||
}));
|
||||
},
|
||||
focusTab: (workspaceKey, tabId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Renders a byte count in the units the file pane and file search already show. */
|
||||
export function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -1,3 +1,16 @@
|
||||
export function isAbsolutePath(value: string): boolean {
|
||||
return value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites separators only for a path that is Windows-shaped, meaning it carries a drive letter
|
||||
* or a UNC prefix. Everywhere else a backslash is an ordinary file name character, so a POSIX or
|
||||
* workspace-relative path is returned untouched.
|
||||
*/
|
||||
export function normalizePathSeparators(value: string): string {
|
||||
return isWindowsShapedPath(value) ? value.replace(/\\/g, "/") : value;
|
||||
}
|
||||
|
||||
function isWindowsShapedPath(value: string): boolean {
|
||||
return value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveWorkspaceFileSelection,
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
resolveWorkspaceFilePaths,
|
||||
@@ -7,10 +8,10 @@ import {
|
||||
} from ".";
|
||||
|
||||
describe("normalizeWorkspaceFileLocation", () => {
|
||||
it("normalizes paths and valid line ranges", () => {
|
||||
it("clamps line ranges and leaves the path as the producer gave it", () => {
|
||||
expect(
|
||||
normalizeWorkspaceFileLocation({
|
||||
path: "src\\app.ts",
|
||||
path: "src/app.ts",
|
||||
lineStart: 12.8,
|
||||
lineEnd: 20.2,
|
||||
}),
|
||||
@@ -34,7 +35,7 @@ describe("normalizeWorkspaceFileLocation", () => {
|
||||
});
|
||||
|
||||
it("rejects empty paths", () => {
|
||||
expect(normalizeWorkspaceFileLocation({ path: " " })).toBeNull();
|
||||
expect(normalizeWorkspaceFileLocation({ path: "" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,13 +98,33 @@ describe("resolveWorkspaceFilePaths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes Windows separators in the file path", () => {
|
||||
it("normalizes separators in a Windows-shaped path and leaves every other path literal", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }),
|
||||
resolveWorkspaceFilePaths({
|
||||
path: "C:\\Users\\me\\repo\\src\\app.ts",
|
||||
workspaceRoot: "C:\\Users\\me\\repo",
|
||||
}),
|
||||
).toEqual({
|
||||
absolutePath: "/Users/me/repo/src/app.ts",
|
||||
absolutePath: "C:/Users/me/repo/src/app.ts",
|
||||
relativePath: "src/app.ts",
|
||||
});
|
||||
// A workspace-relative path is the host's own identity, so its backslash is a file name
|
||||
// character. Rewriting it here is what opened a different file than the search result.
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "a\\b.txt", workspaceRoot: "/Users/me/repo" }),
|
||||
).toEqual({
|
||||
absolutePath: "/Users/me/repo/a\\b.txt",
|
||||
relativePath: "a\\b.txt",
|
||||
});
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({
|
||||
path: "/Users/me/repo/a\\b.txt",
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
absolutePath: "/Users/me/repo/a\\b.txt",
|
||||
relativePath: "a\\b.txt",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes dot segments in workspace-relative paths", () => {
|
||||
@@ -183,3 +204,50 @@ describe("resolveWorkspaceFilePaths", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("selects exact UTF-16 columns and refuses a stale saved match", () => {
|
||||
const location = {
|
||||
path: "a.ts",
|
||||
lineStart: 2,
|
||||
columnStart: 5,
|
||||
columnEnd: 11,
|
||||
expectedText: "NEEDLE",
|
||||
};
|
||||
expect(resolveWorkspaceFileSelection("first\né🙂 NEEDLE", location)).toEqual({
|
||||
from: 10,
|
||||
to: 16,
|
||||
changed: false,
|
||||
});
|
||||
expect(resolveWorkspaceFileSelection("first\né🙂 change", location)).toEqual({
|
||||
from: 10,
|
||||
to: 10,
|
||||
changed: true,
|
||||
});
|
||||
expect(workspaceFileLocationsEqual(location, { ...location, columnStart: 6 })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not select an identical occurrence on another line after the saved line disappears", () => {
|
||||
expect(
|
||||
resolveWorkspaceFileSelection("needle", {
|
||||
path: "a.ts",
|
||||
lineStart: 3,
|
||||
columnStart: 1,
|
||||
columnEnd: 7,
|
||||
expectedText: "needle",
|
||||
}),
|
||||
).toEqual({ from: 0, to: 0, changed: true });
|
||||
});
|
||||
|
||||
it("preserves literal filename and workspace whitespace through location and host paths", () => {
|
||||
const location = { path: " leading.txt ", lineStart: 1 };
|
||||
expect(normalizeWorkspaceFileLocation(location)).toEqual(location);
|
||||
expect(resolveWorkspaceFilePaths({ path: location.path, workspaceRoot: "/workspace " })).toEqual({
|
||||
absolutePath: "/workspace / leading.txt ",
|
||||
relativePath: " leading.txt ",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a literal backslash file name through location identity", () => {
|
||||
const location = { path: "a\\b.txt", lineStart: 1, columnStart: 1, expectedText: "needle" };
|
||||
expect(normalizeWorkspaceFileLocation(location)).toEqual(location);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { isAbsolutePath, normalizePathSeparators } from "@/utils/path";
|
||||
|
||||
export type OpenFileDisposition = "main" | "preferred" | "side";
|
||||
|
||||
export interface WorkspaceFileLocation {
|
||||
path: string;
|
||||
/** One-based source line; CRLF, CR and LF each separate lines. */
|
||||
lineStart?: number;
|
||||
lineEnd?: number;
|
||||
/** One-based UTF-16 column on lineStart. */
|
||||
columnStart?: number;
|
||||
/** Exclusive UTF-16 column on the same line. */
|
||||
columnEnd?: number;
|
||||
/** Original saved occurrence; a mismatch reveals the location without selecting text. */
|
||||
expectedText?: string;
|
||||
}
|
||||
|
||||
export type WorkspaceFileTabTarget = { kind: "file" } & WorkspaceFileLocation;
|
||||
@@ -22,16 +29,25 @@ export function normalizeWorkspaceFileLocation(
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = location.path.trim().replace(/\\/g, "/");
|
||||
// The path is an identity the host already produced, so it is kept verbatim: a backslash is a
|
||||
// file name character outside Windows, and rewriting it here opened a different file than the
|
||||
// one a search result or explorer row named. Callers holding a path scraped out of text
|
||||
// normalize it themselves before arriving.
|
||||
const path = location.path;
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineStart = normalizeLineNumber(location.lineStart);
|
||||
const lineEnd = normalizeLineNumber(location.lineEnd);
|
||||
const columnStart = normalizeLineNumber(location.columnStart);
|
||||
const columnEnd = normalizeLineNumber(location.columnEnd);
|
||||
return {
|
||||
path,
|
||||
...(lineStart ? { lineStart } : {}),
|
||||
...(lineStart && columnStart ? { columnStart } : {}),
|
||||
...(lineStart && columnEnd && columnEnd >= (columnStart ?? 1) ? { columnEnd } : {}),
|
||||
...(location.expectedText !== undefined ? { expectedText: location.expectedText } : {}),
|
||||
...(lineStart && lineEnd && lineEnd >= lineStart ? { lineEnd } : {}),
|
||||
};
|
||||
}
|
||||
@@ -41,7 +57,12 @@ export function workspaceFileLocationsEqual(
|
||||
right: WorkspaceFileLocation,
|
||||
): boolean {
|
||||
return (
|
||||
left.path === right.path && left.lineStart === right.lineStart && left.lineEnd === right.lineEnd
|
||||
left.path === right.path &&
|
||||
left.lineStart === right.lineStart &&
|
||||
left.lineEnd === right.lineEnd &&
|
||||
left.columnStart === right.columnStart &&
|
||||
left.columnEnd === right.columnEnd &&
|
||||
left.expectedText === right.expectedText
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,8 +166,8 @@ export function resolveWorkspaceFilePaths(input: {
|
||||
path: string;
|
||||
workspaceRoot: string;
|
||||
}): ResolvedWorkspaceFilePaths | null {
|
||||
const filePath = input.path.trim().replace(/\\/g, "/");
|
||||
const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
|
||||
const filePath = normalizePathSeparators(input.path);
|
||||
const workspaceRoot = normalizeAbsolutePath(normalizePathSeparators(input.workspaceRoot));
|
||||
if (!filePath || !workspaceRoot) {
|
||||
return null;
|
||||
}
|
||||
@@ -176,3 +197,26 @@ export function resolveWorkspaceFilePaths(input: {
|
||||
}
|
||||
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
|
||||
}
|
||||
|
||||
/** Resolves a saved location without manufacturing a selection when its text has moved. */
|
||||
export function resolveWorkspaceFileSelection(
|
||||
content: string,
|
||||
location: WorkspaceFileLocation,
|
||||
): { from: number; to: number; changed: boolean } {
|
||||
const lines = content.split("\n");
|
||||
const index = Math.max(0, Math.min((location.lineStart ?? 1) - 1, lines.length - 1));
|
||||
const line = lines[index];
|
||||
const offset = lines.slice(0, index).reduce((sum, value) => sum + value.length + 1, 0);
|
||||
const from = offset + Math.min((location.columnStart ?? 1) - 1, line.length);
|
||||
const endIndex = Math.max(index, Math.min((location.lineEnd ?? index + 1) - 1, lines.length - 1));
|
||||
let to = from;
|
||||
if (location.columnEnd !== undefined) to = offset + Math.min(location.columnEnd - 1, line.length);
|
||||
else if (endIndex > index)
|
||||
to =
|
||||
lines.slice(0, endIndex).reduce((sum, value) => sum + value.length + 1, 0) +
|
||||
lines[endIndex].length;
|
||||
const changed =
|
||||
location.expectedText !== undefined &&
|
||||
(index !== (location.lineStart ?? 1) - 1 || content.slice(from, to) !== location.expectedText);
|
||||
return { from, to: changed ? from : to, changed };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.8.0",
|
||||
"@getpaseo/protocol": "0.8.0",
|
||||
"@getpaseo/server": "0.8.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/server": "0.8.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.8.0",
|
||||
"@getpaseo/relay": "0.8.0",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WorkspaceContentSearchResult } from "@getpaseo/protocol/messages";
|
||||
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
@@ -4491,6 +4492,33 @@ export class DaemonClient {
|
||||
return payload.directory;
|
||||
}
|
||||
|
||||
async searchWorkspaceContent(
|
||||
input: { cwd: string; query: string },
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<WorkspaceContentSearchResult> {
|
||||
if (options.signal?.aborted)
|
||||
return { status: "error", code: "cancelled", message: "Search cancelled" };
|
||||
const requestId = this.createRequestId();
|
||||
const cancel = () => {
|
||||
void this.sendCorrelatedSessionRequest({
|
||||
message: { type: "fs.content.cancel.request", searchRequestId: requestId },
|
||||
responseType: "fs.content.cancel.response",
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
const response = this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: { type: "fs.content.search.request", ...input },
|
||||
responseType: "fs.content.search.response",
|
||||
});
|
||||
options.signal?.addEventListener("abort", cancel, { once: true });
|
||||
if (options.signal?.aborted) cancel();
|
||||
try {
|
||||
return (await response).result;
|
||||
} finally {
|
||||
options.signal?.removeEventListener("abort", cancel);
|
||||
}
|
||||
}
|
||||
|
||||
async readFile(
|
||||
cwd: string,
|
||||
path: string,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export type { FileReadResult } from "./daemon-client.js";
|
||||
import type { DaemonClientConfig } from "./daemon-client.js";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
|
||||
@@ -5,7 +5,6 @@ const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { setTimeout: delay } = require("node:timers/promises");
|
||||
const { chromium } = require("playwright");
|
||||
const { extractFile } = require("@electron/asar");
|
||||
|
||||
const EXECUTABLE_NAME = "Paseo";
|
||||
const SMOKE_TIMEOUT_MS = 60_000;
|
||||
@@ -806,23 +805,9 @@ async function stopCliDaemon({ appPath, env }) {
|
||||
});
|
||||
}
|
||||
|
||||
function assertLinuxDesktopIdentity(appPath) {
|
||||
if (process.platform === "linux") {
|
||||
const metadata = JSON.parse(
|
||||
extractFile(path.join(appPath, "resources", "app.asar"), "package.json").toString(),
|
||||
);
|
||||
if (metadata.desktopName !== `${EXECUTABLE_NAME}.desktop`) {
|
||||
throw new Error(
|
||||
`Packaged Linux desktop identity ${JSON.stringify(metadata.desktopName)} does not match ${EXECUTABLE_NAME}.desktop`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function smokePackagedDesktopApp({ appPath }) {
|
||||
const executablePath = getExecutablePath(appPath);
|
||||
assertExecutable(executablePath, "Packaged app executable");
|
||||
assertLinuxDesktopIdentity(appPath);
|
||||
ensureLinuxSandboxPermissions(appPath);
|
||||
await smokeColdCliDaemonStart({ appPath });
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
@@ -35,7 +35,6 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "44.2.0",
|
||||
@@ -44,6 +43,5 @@
|
||||
"unzip-crx-3": "^0.2.0",
|
||||
"vitest": "^4.1.6",
|
||||
"wait-on": "8.0.5"
|
||||
},
|
||||
"desktopName": "Paseo.desktop"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -65,8 +65,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.8.0",
|
||||
"@getpaseo/protocol": "0.8.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -76,8 +76,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.8.0",
|
||||
"@getpaseo/protocol": "0.8.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -2688,6 +2688,57 @@ export const FileVersionSchema = z.discriminatedUnion("status", [
|
||||
}),
|
||||
]);
|
||||
|
||||
export const WorkspaceContentMatchSchema = z.object({
|
||||
path: z.string(),
|
||||
line: z.number().int().positive(),
|
||||
columnStart: z.number().int().positive(),
|
||||
columnEnd: z.number().int().positive(),
|
||||
text: z.string(),
|
||||
snippet: z.string(),
|
||||
snippetMatchStart: z.number().int().nonnegative(),
|
||||
snippetMatchEnd: z.number().int().nonnegative(),
|
||||
});
|
||||
export const WorkspaceContentSearchResultSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("ok"),
|
||||
matches: z.array(WorkspaceContentMatchSchema),
|
||||
limited: z.boolean(),
|
||||
/**
|
||||
* Per-file byte ceiling the host applied. Files above it are never opened, so a result set
|
||||
* is complete only for files at or under this size. Absent on hosts that do not report it.
|
||||
*/
|
||||
// COMPAT(workspaceContentSearchMaxFileBytes): added in v0.8.0, remove optionality after
|
||||
// 2027-03-10 once the daemon floor reports it.
|
||||
maxFileBytes: z.number().int().positive().optional(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("error"),
|
||||
code: z.enum(["missing_rg", "timeout", "invalid_query", "unavailable", "busy", "cancelled"]),
|
||||
message: z.string(),
|
||||
}),
|
||||
]);
|
||||
export const WorkspaceContentSearchRequestSchema = z.object({
|
||||
type: z.literal("fs.content.search.request"),
|
||||
requestId: z.string(),
|
||||
cwd: z.string(),
|
||||
query: z.string(),
|
||||
});
|
||||
export const WorkspaceContentCancelRequestSchema = z.object({
|
||||
type: z.literal("fs.content.cancel.request"),
|
||||
requestId: z.string(),
|
||||
searchRequestId: z.string(),
|
||||
});
|
||||
export const WorkspaceContentSearchResponseSchema = z.object({
|
||||
type: z.literal("fs.content.search.response"),
|
||||
payload: z.object({ requestId: z.string(), result: WorkspaceContentSearchResultSchema }),
|
||||
});
|
||||
export const WorkspaceContentCancelResponseSchema = z.object({
|
||||
type: z.literal("fs.content.cancel.response"),
|
||||
payload: z.object({ requestId: z.string(), searchRequestId: z.string() }),
|
||||
});
|
||||
export type WorkspaceContentMatch = z.infer<typeof WorkspaceContentMatchSchema>;
|
||||
export type WorkspaceContentSearchResult = z.infer<typeof WorkspaceContentSearchResultSchema>;
|
||||
|
||||
export const FileSubscribeRequestSchema = z.object({
|
||||
type: z.literal("fs.file.subscribe.request"),
|
||||
cwd: z.string(),
|
||||
@@ -3211,6 +3262,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
WorkspaceClearAttentionRequestSchema,
|
||||
WorkspaceMarkUnreadRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
WorkspaceContentSearchRequestSchema,
|
||||
WorkspaceContentCancelRequestSchema,
|
||||
FileSubscribeRequestSchema,
|
||||
FileUnsubscribeRequestSchema,
|
||||
FileWriteRequestSchema,
|
||||
@@ -3516,6 +3569,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
workspaceRecovery: z.boolean().optional(),
|
||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||
workspaceFileEditing: z.boolean().optional(),
|
||||
workspaceContentSearch: z.boolean().optional(),
|
||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||
providerUsageList: z.boolean().optional(),
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
@@ -6617,6 +6671,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoWorktreeArchiveResponseSchema,
|
||||
CreatePaseoWorktreeResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
WorkspaceContentSearchResponseSchema,
|
||||
WorkspaceContentCancelResponseSchema,
|
||||
FileSubscribeResponseSchema,
|
||||
FileUnsubscribeResponseSchema,
|
||||
FileWriteResponseSchema,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -71,11 +71,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.8.0",
|
||||
"@getpaseo/highlight": "0.8.0",
|
||||
"@getpaseo/plugin": "0.8.0",
|
||||
"@getpaseo/protocol": "0.8.0",
|
||||
"@getpaseo/relay": "0.8.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/highlight": "0.8.0-beta.1",
|
||||
"@getpaseo/plugin": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -19,7 +19,6 @@ import { toAgentPayload } from "./agent-projections.js";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js";
|
||||
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
import type {
|
||||
@@ -69,12 +68,6 @@ function deferred<T>(): Deferred<T> {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function drainAsyncGenerator<T>(generator: AsyncGenerator<T>): Promise<void> {
|
||||
for await (const _ of generator) {
|
||||
// Drain provider events while AgentManager subscribers observe them.
|
||||
}
|
||||
}
|
||||
|
||||
function waitForAgentLifecycle(
|
||||
manager: AgentManager,
|
||||
agentId: string,
|
||||
@@ -526,32 +519,6 @@ class TestAgentSession implements AgentSession {
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
class ResumeTrackingTestAgentClient extends TestAgentClient {
|
||||
private readonly retryStarted = deferred<void>();
|
||||
|
||||
override async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
this.resumeOverrides.push(config);
|
||||
const signalRetryStarted = () => this.retryStarted.resolve();
|
||||
return new (class extends TestAgentSession {
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
signalRetryStarted();
|
||||
return await super.startTurn();
|
||||
}
|
||||
})({
|
||||
provider: this.provider,
|
||||
cwd: config?.cwd ?? process.cwd(),
|
||||
daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
waitForRetryStart(): Promise<void> {
|
||||
return this.retryStarted.promise;
|
||||
}
|
||||
}
|
||||
|
||||
class McpCapableTestAgentSession extends TestAgentSession {
|
||||
override readonly capabilities = {
|
||||
...TEST_CAPABILITIES,
|
||||
@@ -3710,201 +3677,6 @@ test("updateProviderRegistry removes providers omitted from the next registry",
|
||||
expect(removedClient.createSessionCalls).toBe(0);
|
||||
});
|
||||
|
||||
test("retires loaded agents when their plugin provider is replaced", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-plugin-provider-reload-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const provider = "plugin-provider";
|
||||
|
||||
class OriginalPluginClient extends TestAgentClient {
|
||||
session: CloseRecordingTestAgentSession | null = null;
|
||||
|
||||
constructor() {
|
||||
super(provider);
|
||||
}
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const session = new CloseRecordingTestAgentSession(config);
|
||||
session.describePersistence = () => ({
|
||||
provider,
|
||||
sessionId: "plugin-session",
|
||||
});
|
||||
this.session = session;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
const original = new OriginalPluginClient();
|
||||
const replacement = new TestAgentClient(provider);
|
||||
const manager = new AgentManager({
|
||||
clients: { [provider]: original },
|
||||
providerDefinitions: { [provider]: { enabled: true } },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const created = await manager.createAgent({ provider, cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
manager.updateProviderRegistry({
|
||||
providerDefinitions: { [provider]: { enabled: true } },
|
||||
clients: { [provider]: replacement },
|
||||
retiredProviders: [provider],
|
||||
});
|
||||
|
||||
await manager.waitForAgentClose(created.id);
|
||||
expect(original.session?.closed).toBe(true);
|
||||
expect(manager.getAgent(created.id)).toBeNull();
|
||||
|
||||
const resumed = await ensureAgentLoaded(created.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
expect(resumed.id).toBe(created.id);
|
||||
expect(replacement.resumeOverrides).toEqual([
|
||||
expect.objectContaining({ cwd: workdir, provider }),
|
||||
]);
|
||||
} finally {
|
||||
await manager.closeAgent(created.id).catch(() => undefined);
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a prompt after provider replacement reopens the stale session", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-prompt-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const provider = "plugin-provider";
|
||||
|
||||
class StalePluginSession extends CloseRecordingTestAgentSession {
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
throw new StaleProviderSessionError("stale-bridge");
|
||||
}
|
||||
}
|
||||
|
||||
class StalePluginClient extends TestAgentClient {
|
||||
constructor() {
|
||||
super(provider);
|
||||
}
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const session = new StalePluginSession(config);
|
||||
session.describePersistence = () => ({
|
||||
provider,
|
||||
sessionId: "plugin-session",
|
||||
});
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
const staleClient = new StalePluginClient();
|
||||
const replacement = new ResumeTrackingTestAgentClient(provider);
|
||||
const manager = new AgentManager({
|
||||
clients: { [provider]: staleClient },
|
||||
providerDefinitions: { [provider]: { enabled: true } },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const created = await manager.createAgent({ provider, cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
manager.updateProviderRegistry({
|
||||
providerDefinitions: { [provider]: { enabled: true } },
|
||||
clients: { [provider]: replacement },
|
||||
});
|
||||
|
||||
const dispatch = await startAgentRun(manager, created.id, "continue after reload", logger);
|
||||
expect(dispatch.disposition).toBe("turn_started");
|
||||
await replacement.waitForRetryStart();
|
||||
const result = await manager.waitForAgentEvent(created.id);
|
||||
expect(result.status).toBe("idle");
|
||||
expect(replacement.resumeOverrides).toHaveLength(1);
|
||||
} finally {
|
||||
await manager.closeAgent(created.id).catch(() => undefined);
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a replacement prompt recovers when the retired session fails to start", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-replacement-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const provider = "plugin-provider";
|
||||
|
||||
class StaleReplacementSession extends TestAgentSession {
|
||||
private starts = 0;
|
||||
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
this.starts += 1;
|
||||
if (this.starts > 1) throw new StaleProviderSessionError("stale-bridge");
|
||||
return { turnId: "initial-turn" };
|
||||
}
|
||||
|
||||
override async interrupt(): Promise<void> {
|
||||
if (this.starts > 1) throw new StaleProviderSessionError("stale-bridge");
|
||||
this.pushEvent({
|
||||
type: "turn_canceled",
|
||||
provider: this.provider,
|
||||
turnId: "initial-turn",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class StaleReplacementClient extends TestAgentClient {
|
||||
constructor() {
|
||||
super(provider);
|
||||
}
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const session = new StaleReplacementSession(config);
|
||||
session.describePersistence = () => ({ provider, sessionId: "plugin-session" });
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
const replacement = new ResumeTrackingTestAgentClient(provider);
|
||||
const manager = new AgentManager({
|
||||
clients: { [provider]: new StaleReplacementClient() },
|
||||
providerDefinitions: { [provider]: { enabled: true } },
|
||||
registry: storage,
|
||||
logger,
|
||||
rescueTimeouts: { interruptSessionMs: 20 },
|
||||
});
|
||||
const created = await manager.createAgent({ provider, cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
const initial = manager.streamAgent(created.id, "initial");
|
||||
void drainAsyncGenerator(initial);
|
||||
await manager.waitForAgentRunStart(created.id);
|
||||
|
||||
manager.updateProviderRegistry({
|
||||
providerDefinitions: { [provider]: { enabled: true } },
|
||||
clients: { [provider]: replacement },
|
||||
});
|
||||
|
||||
const dispatch = await startAgentRun(manager, created.id, "replacement", logger, {
|
||||
replaceRunning: true,
|
||||
});
|
||||
expect(dispatch.disposition).toBe("turn_started");
|
||||
await replacement.waitForRetryStart();
|
||||
const result = await manager.waitForAgentEvent(created.id);
|
||||
expect(result.status).toBe("idle");
|
||||
expect(replacement.resumeOverrides).toHaveLength(1);
|
||||
} finally {
|
||||
await manager.closeAgent(created.id).catch(() => undefined);
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createAgent passes explicit model strings through to the provider", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -77,7 +77,6 @@ import {
|
||||
} from "./agent-run-state.js";
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
|
||||
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
||||
import { resolveCreateAgentTitles } from "./create-agent-title.js";
|
||||
import type { PaseoToolCatalogFactory } from "./tools/types.js";
|
||||
@@ -776,7 +775,6 @@ export class AgentManager {
|
||||
updateProviderRegistry(input: {
|
||||
providerDefinitions: ProviderEnabledMap;
|
||||
clients: ProviderClientMap;
|
||||
retiredProviders?: readonly AgentProvider[];
|
||||
}): void {
|
||||
this.providerEnabled.clear();
|
||||
this.providerDefinitions.clear();
|
||||
@@ -793,18 +791,6 @@ export class AgentManager {
|
||||
this.clients.set(provider, client);
|
||||
}
|
||||
}
|
||||
|
||||
for (const provider of input.retiredProviders ?? []) {
|
||||
for (const agent of this.agents.values()) {
|
||||
if (agent.provider !== provider) continue;
|
||||
void this.closeAgent(agent.id).catch((error) => {
|
||||
this.logger.warn(
|
||||
{ err: error, agentId: agent.id, provider },
|
||||
"Failed to close agent after provider retirement",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRegisteredProviderIds(): AgentProvider[] {
|
||||
@@ -2374,13 +2360,6 @@ export class AgentManager {
|
||||
if (pendingRun.settled) {
|
||||
throw error;
|
||||
}
|
||||
if (isStaleProviderSessionError(error)) {
|
||||
pendingRun.start = { status: "failed", error: error.message };
|
||||
agent.pendingReplacement = false;
|
||||
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
|
||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||
throw error;
|
||||
}
|
||||
agent.pendingReplacement = false;
|
||||
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
|
||||
pendingRun.start = { status: "failed", error: errorMsg };
|
||||
@@ -2906,9 +2885,6 @@ export class AgentManager {
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
if (agent.inFlightPermissionResponses.has(requestId)) {
|
||||
throw new Error("A response to this permission request is already being submitted");
|
||||
}
|
||||
agent.inFlightPermissionResponses.add(requestId);
|
||||
|
||||
try {
|
||||
@@ -3103,11 +3079,8 @@ export class AgentManager {
|
||||
epoch: this.timelineStore.getEpoch(agentId),
|
||||
});
|
||||
}
|
||||
// Rewind stages provider events under the run lock; publish its final state directly.
|
||||
this.refreshSessionPersistence(agent);
|
||||
await this.refreshSessionState(agent, { emit: false });
|
||||
await this.refreshRuntimeInfo(agent);
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent, { persist: false });
|
||||
this.logger.info(
|
||||
{ agentId, provider: agent.provider, messageId, mode },
|
||||
"agent.rewind.complete",
|
||||
@@ -4258,18 +4231,14 @@ export class AgentManager {
|
||||
|
||||
private onStreamThreadStarted(agent: ActiveManagedAgent): void {
|
||||
const previousSessionId = agent.persistence?.sessionId ?? null;
|
||||
this.refreshSessionPersistence(agent);
|
||||
if (agent.persistence?.sessionId !== previousSessionId) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
|
||||
private refreshSessionPersistence(agent: ActiveManagedAgent): void {
|
||||
const handle = agent.session.describePersistence();
|
||||
if (handle) {
|
||||
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
|
||||
if (agent.persistence?.sessionId !== previousSessionId) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
}
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
|
||||
private async onStreamTimelineEvent(params: {
|
||||
@@ -4474,7 +4443,6 @@ export class AgentManager {
|
||||
): void {
|
||||
const hadPendingPermissions = agent.pendingPermissions.size > 0;
|
||||
agent.pendingPermissions.set(event.request.id, event.request);
|
||||
this.refreshSessionPersistence(agent);
|
||||
if (!hadPendingPermissions && !agent.internal) {
|
||||
this.broadcastAgentAttention(agent, "permission");
|
||||
}
|
||||
@@ -4489,7 +4457,6 @@ export class AgentManager {
|
||||
}): void {
|
||||
const { agent, event, options, flags } = params;
|
||||
agent.pendingPermissions.delete(event.requestId);
|
||||
this.refreshSessionPersistence(agent);
|
||||
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
|
||||
agent.bufferedPermissionResolutions.set(event.requestId, event);
|
||||
flags.shouldDispatchEvent = false;
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -22,9 +21,7 @@ export type AgentRunController = Pick<
|
||||
| "replaceAgentRun"
|
||||
| "steerOrReplaceActiveTurn"
|
||||
| "streamAgent"
|
||||
> & {
|
||||
reloadAgentSession(agentId: string): Promise<unknown>;
|
||||
};
|
||||
>;
|
||||
|
||||
export interface StartAgentRunOptions {
|
||||
replaceRunning?: boolean;
|
||||
@@ -81,14 +78,6 @@ async function startOrReplaceRun(
|
||||
return { iterator, replaced };
|
||||
}
|
||||
|
||||
async function drainAgentRunIterator(
|
||||
iterator: AsyncGenerator<import("./agent-sdk-types.js").AgentStreamEvent>,
|
||||
): Promise<void> {
|
||||
for await (const _ of iterator) {
|
||||
// Events are broadcast via AgentManager subscribers.
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAgentRun(
|
||||
agentManager: AgentRunController,
|
||||
agentId: string,
|
||||
@@ -115,26 +104,6 @@ export async function startAgentRun(
|
||||
if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
|
||||
return { disposition: "out_of_band" };
|
||||
}
|
||||
try {
|
||||
return await startAgentRunInner(agentManager, agentId, prompt, logger, options);
|
||||
} catch (error) {
|
||||
if (!isStaleProviderSessionError(error)) throw error;
|
||||
logger.info({ agentId, err: error }, "Provider session went stale; reopening from persistence");
|
||||
// The live session belongs to a retired plugin runtime. Reload swaps in a
|
||||
// fresh session on the current runtime while preserving history and labels.
|
||||
await agentManager.reloadAgentSession(agentId);
|
||||
return await startAgentRunInner(agentManager, agentId, prompt, logger, options);
|
||||
}
|
||||
}
|
||||
|
||||
async function startAgentRunInner(
|
||||
agentManager: AgentRunController,
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
logger: Logger,
|
||||
options?: StartAgentRunOptions,
|
||||
): Promise<{ disposition: PromptDispatchDisposition }> {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
const steered = await steerOrReplaceActiveRun(agentManager, agentId, prompt, options);
|
||||
if (steered?.disposition === "steered") {
|
||||
return steered;
|
||||
@@ -153,17 +122,8 @@ async function startAgentRunInner(
|
||||
);
|
||||
void (async () => {
|
||||
try {
|
||||
try {
|
||||
await drainAgentRunIterator(iterator);
|
||||
} catch (error) {
|
||||
if (!isStaleProviderSessionError(error)) throw error;
|
||||
logger.info(
|
||||
{ agentId, err: error },
|
||||
"Provider session went stale; reopening from persistence",
|
||||
);
|
||||
await agentManager.reloadAgentSession(agentId);
|
||||
const retry = await startOrReplaceRun(agentManager, agentId, prompt, options);
|
||||
await drainAgentRunIterator(retry.iterator);
|
||||
for await (const _ of iterator) {
|
||||
// Events are broadcast via AgentManager subscribers.
|
||||
}
|
||||
logger.trace(
|
||||
{
|
||||
|
||||
@@ -8,10 +8,6 @@ import { describe, expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "./agent-sdk-types.js";
|
||||
import { PluginAgentClientRegistry } from "./plugin-provider.js";
|
||||
import {
|
||||
isStaleProviderSessionError,
|
||||
StaleProviderSessionError,
|
||||
} from "./stale-provider-session-error.js";
|
||||
|
||||
const CAPABILITIES = [
|
||||
"prompt.message",
|
||||
@@ -29,10 +25,6 @@ interface ProviderHarnessOptions {
|
||||
function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
let listener: ((event: ProviderEvent) => void) | null = null;
|
||||
let closeCount = 0;
|
||||
let resolveClosed!: () => void;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
resolveClosed = resolve;
|
||||
});
|
||||
const inputs: ProviderInput[] = [];
|
||||
const emit = (event: ProviderEvent) => listener?.(event);
|
||||
const capabilities = options.capabilities ?? CAPABILITIES;
|
||||
@@ -188,7 +180,6 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
},
|
||||
async close() {
|
||||
closeCount += 1;
|
||||
resolveClosed();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -200,12 +191,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
registration,
|
||||
inputs,
|
||||
closeCount: () => closeCount,
|
||||
waitForClose: () => closed,
|
||||
};
|
||||
return { registration, inputs, closeCount: () => closeCount };
|
||||
}
|
||||
|
||||
function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]) {
|
||||
@@ -272,84 +258,6 @@ describe("PluginAgentClientRegistry", () => {
|
||||
expect(eventsOfType(events, "turn_failed")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("closes a stale session after its plugin provider is replaced", async () => {
|
||||
const old = createProviderHarness();
|
||||
const next = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
|
||||
try {
|
||||
registry.replace([old.registration]);
|
||||
|
||||
const stale = await registry.clients()[old.registration.id]!.createSession({
|
||||
provider: old.registration.id,
|
||||
cwd: "/workspace",
|
||||
});
|
||||
const persistence = stale.describePersistence();
|
||||
expect(persistence).not.toBeNull();
|
||||
|
||||
registry.replace([next.registration]);
|
||||
await old.waitForClose();
|
||||
expect(old.closeCount()).toBe(1);
|
||||
|
||||
await expect(stale.close()).resolves.toBeUndefined();
|
||||
|
||||
const replacement = registry.clients()[next.registration.id];
|
||||
expect(replacement).toBeDefined();
|
||||
const resumed = await replacement!.resumeSession(persistence!, {
|
||||
cwd: "/workspace",
|
||||
});
|
||||
|
||||
await expect(
|
||||
resumed.startTurn("after reload", { clientMessageId: "after-reload" }),
|
||||
).resolves.toEqual({ turnId: "turn-1" });
|
||||
|
||||
expect(next.inputs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.open",
|
||||
history: "replay",
|
||||
persistence: {
|
||||
version: 1,
|
||||
data: { token: "root" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await resumed.close();
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("prompting a stale session raises StaleProviderSessionError", async () => {
|
||||
const old = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
|
||||
try {
|
||||
registry.replace([old.registration]);
|
||||
const stale = await registry.clients()[old.registration.id]!.createSession({
|
||||
provider: old.registration.id,
|
||||
cwd: "/workspace",
|
||||
});
|
||||
|
||||
registry.replace([]);
|
||||
await old.waitForClose();
|
||||
expect(old.closeCount()).toBe(1);
|
||||
|
||||
const failure = await stale
|
||||
.startTurn("after reload", { clientMessageId: "after-reload" })
|
||||
.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
expect(failure).toBeInstanceOf(StaleProviderSessionError);
|
||||
expect(isStaleProviderSessionError(failure)).toBe(true);
|
||||
expect(isStaleProviderSessionError(new Error("Provider connection is closed"))).toBe(false);
|
||||
expect(isStaleProviderSessionError(new Error("boom"))).toBe(false);
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("adapts callback providers into the existing AgentClient and AgentSession path", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
|
||||
@@ -58,7 +58,6 @@ import {
|
||||
} from "./create-agent-mode.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import { runProviderTurn } from "./providers/provider-runner.js";
|
||||
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
|
||||
interface Deferred<Value> {
|
||||
promise: Promise<Value>;
|
||||
@@ -131,10 +130,6 @@ class ProviderRuntime {
|
||||
return this.connection?.capabilities ?? [];
|
||||
}
|
||||
|
||||
get isClosed(): boolean {
|
||||
return this.closed;
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
await this.getConnection();
|
||||
return true;
|
||||
@@ -564,7 +559,6 @@ class ProviderRuntimeSession {
|
||||
sessionId: this.providerSessionId,
|
||||
prompt,
|
||||
});
|
||||
if (this.terminal) throw new StaleProviderSessionError(this.id);
|
||||
const pending = deferred<Extract<ProviderEvent, { type: "session.prompt_result" }>>();
|
||||
this.prompts.set(prompt.clientMessageId, pending);
|
||||
try {
|
||||
@@ -574,9 +568,6 @@ class ProviderRuntimeSession {
|
||||
prompt,
|
||||
});
|
||||
return (await pending.promise).result;
|
||||
} catch (error) {
|
||||
if (this.terminal || this.runtime.isClosed) throw new StaleProviderSessionError(this.id);
|
||||
throw error;
|
||||
} finally {
|
||||
this.prompts.delete(prompt.clientMessageId);
|
||||
}
|
||||
@@ -632,8 +623,6 @@ class ProviderRuntimeSession {
|
||||
requestId: randomUUID(),
|
||||
sessionId: this.providerSessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!this.runtime.isClosed) throw error;
|
||||
} finally {
|
||||
this.runtime.removeSession(this.id, this.providerSessionId);
|
||||
}
|
||||
|
||||
@@ -2281,7 +2281,6 @@ describe("ProviderSnapshotManager cwd routing", () => {
|
||||
const withoutPlugin = manager.replacePluginProviders([]);
|
||||
expect(manager.hasProvider(registration.id)).toBe(false);
|
||||
expect(withoutPlugin.clients[registration.id]).toBeUndefined();
|
||||
expect(withoutPlugin.retiredProviders).toEqual([registration.id]);
|
||||
} finally {
|
||||
await manager.shutdown();
|
||||
manager.destroy();
|
||||
|
||||
@@ -191,7 +191,6 @@ export interface AgentManagerProviderState {
|
||||
>
|
||||
>;
|
||||
clients: Partial<Record<AgentProvider, AgentClient>>;
|
||||
retiredProviders?: readonly AgentProvider[];
|
||||
}
|
||||
|
||||
interface ProviderLoadOptions {
|
||||
@@ -403,9 +402,6 @@ export class ProviderSnapshotManager {
|
||||
this.createAgentManagerState(this.generation.definitions, clients);
|
||||
this.pluginProviders.replace(registrations);
|
||||
const plugins = this.pluginProviders.definitions();
|
||||
const retiredProviders = Object.keys(previousPlugins).filter(
|
||||
(provider) => previousPlugins[provider] !== plugins[provider],
|
||||
);
|
||||
const definitions = { ...this.generation.definitions };
|
||||
const changed = new Set<AgentProvider>();
|
||||
for (const provider of new Set([...Object.keys(previousPlugins), ...Object.keys(plugins)])) {
|
||||
@@ -419,7 +415,7 @@ export class ProviderSnapshotManager {
|
||||
const generation = this.createGeneration(definitions, this.providerOverrides);
|
||||
const state = this.createAgentManagerState(definitions, clients);
|
||||
this.installGeneration(generation, clients, changed);
|
||||
return { ...state, retiredProviders };
|
||||
return state;
|
||||
}
|
||||
|
||||
private ensureClient(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user