mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e8a44848 | ||
|
|
81bffbd01a | ||
|
|
9519c5ba99 | ||
|
|
194822b939 | ||
|
|
2505075beb | ||
|
|
0c3c61be94 | ||
|
|
7046b118e9 | ||
|
|
3fed2f2cd9 | ||
|
|
234f0d9b75 | ||
|
|
548e15c177 | ||
|
|
6240b2d17c | ||
|
|
4e99e4c18c | ||
|
|
2f07c5918f | ||
|
|
3e6061b169 | ||
|
|
4ed944db2f | ||
|
|
af244d0f02 | ||
|
|
f2124f8cf9 | ||
|
|
152cdcabad | ||
|
|
44da3cdf05 | ||
|
|
a8c6c2f4bc | ||
|
|
faccbaae59 | ||
|
|
e61c06706c | ||
|
|
c54ddfd07f | ||
|
|
ddaaaa27be | ||
|
|
fbd02437dc | ||
|
|
b447a650a7 | ||
|
|
4d2acfbeb5 | ||
|
|
d6bc201f46 | ||
|
|
5e75e463ec | ||
|
|
35d4ae201a | ||
|
|
b93d326fb2 | ||
|
|
9505e43925 | ||
|
|
2f0e3451de | ||
|
|
28cac5859a | ||
|
|
59d9a25a0d | ||
|
|
60462337b6 | ||
|
|
f44ef807ef | ||
|
|
f030ddefb1 | ||
|
|
f7a6548b68 | ||
|
|
ab9c9b96a7 | ||
|
|
8fe11e3bb0 |
@@ -200,6 +200,41 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
import-electron:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build app and server dependencies
|
||||
run: |
|
||||
npm run build:app-deps
|
||||
npm run build:server
|
||||
|
||||
- name: Build Desktop main
|
||||
run: npm run build:main --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Run product import behavior
|
||||
run: npm run test:import-electron --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Upload import behavior diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: import-electron-macos
|
||||
path: |
|
||||
packages/app/test-results/
|
||||
packages/app/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
@@ -1,43 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0-beta.2 - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309))
|
||||
- Automate local and worktree-backed workspaces through the CLI and agent tools ([#2186](https://github.com/getpaseo/paseo/pull/2186))
|
||||
- Start a new workspace from a pasted pull request or merge request link ([#2290](https://github.com/getpaseo/paseo/pull/2290))
|
||||
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
|
||||
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
|
||||
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
|
||||
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
|
||||
- Use Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
|
||||
|
||||
### Improved
|
||||
|
||||
- Workspace and chat sync uses less data without losing or reordering history ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
|
||||
- Added folders remain independent projects across nested repositories, worktrees, and filesystem mounts ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
|
||||
- Idle agents release provider resources automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
|
||||
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
|
||||
- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn))
|
||||
- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312))
|
||||
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
|
||||
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
|
||||
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
|
||||
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
|
||||
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
|
||||
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
|
||||
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
|
||||
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
|
||||
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
|
||||
- Session imports find older matches from the current workspace ([#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
|
||||
|
||||
## 0.2.0-beta.1 - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
+13
-1
@@ -91,7 +91,19 @@ it does not depend on the server.
|
||||
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
|
||||
facade. App and CLI may import the low-level driver from
|
||||
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
|
||||
code imports from `@getpaseo/client`.
|
||||
code imports from `@getpaseo/client`. Node automation uses
|
||||
`@getpaseo/client/node`, which owns daemon discovery, authentication, transport
|
||||
selection, and the capability-gated host automation facade shared by the CLI and
|
||||
published import tools.
|
||||
|
||||
### External project import
|
||||
|
||||
The private [`getpaseo/import`](https://github.com/getpaseo/import) repository publishes
|
||||
`@getpaseo/import`. It owns source discovery, parsing, mapping, fixtures, the CLI, and the
|
||||
programmatic import engine. Paseo Desktop pins an exact package version and calls its library
|
||||
entrypoint from Electron main, passing the public Node host-automation facade and forwarding typed
|
||||
progress events through a narrow IPC bridge. Paseo does not contain source databases, parsers, or
|
||||
import-package release machinery.
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
|
||||
@@ -190,10 +190,6 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
},
|
||||
worktrees?: {
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
servicePorts?: { // optional dynamic service port allocation policy
|
||||
range?: string // inclusive range, e.g. "3000-4000"
|
||||
portScript?: string // executable that receives service/workspace context and prints one TCP port
|
||||
}
|
||||
},
|
||||
providers: {
|
||||
openai: {
|
||||
|
||||
@@ -64,8 +64,6 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
||||
|
||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.xs` labels, `sm` = 32px with `fontSize.sm`, `md`/`lg` = 44px with `fontSize.sm`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and horizontal padding. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
---
|
||||
|
||||
@@ -278,15 +278,6 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
|
||||
}
|
||||
```
|
||||
|
||||
Service ports use OS ephemeral allocation by default. Set `worktrees.servicePorts` in
|
||||
`$PASEO_HOME/config.json`, or replace it for one project with `worktree.servicePorts` in
|
||||
`paseo.json`. The block accepts an inclusive `range` such as `"3000-4000"` or a `portScript`
|
||||
executable. Since `portScript` is executed directly without a shell, it must point to a real executable (e.g., a binary or a script with a proper shebang like `#!/bin/sh`) rather than an inline shell command or shell pipeline. For inline shell commands or pipelines, wrap them in a small script. `portScript` runs in the workspace directory with four arguments: service name,
|
||||
workspace ID, branch name, and worktree path. A missing branch is passed as an empty string. The same
|
||||
values are available as `PASEO_SCRIPTNAME`, `PASEO_WORKSPACE_ID`, `PASEO_BRANCH_NAME`, and
|
||||
`PASEO_WORKTREE_PATH`. The script must print one valid TCP port. Paseo trusts the external allocator,
|
||||
so the port may already be bound. `portScript` takes precedence when both values are present.
|
||||
|
||||
## Bundled daemon web UI
|
||||
|
||||
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
|
||||
|
||||
@@ -57,7 +57,7 @@ Android touches sailed straight through to the chat scroll view behind it.
|
||||
|
||||
Two escape hatches in the codebase:
|
||||
|
||||
- **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so
|
||||
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
|
||||
hit-testing starts fresh in that window. Side effect: a Modal opening on
|
||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||
@@ -70,13 +70,7 @@ Two escape hatches in the codebase:
|
||||
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
|
||||
cover them.
|
||||
|
||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||
|
||||
On web, dropdown menus render into the shared `overlay-root`, not React Native
|
||||
Web's `<Modal>`/`<dialog>`. A browser top-layer dialog always paints above
|
||||
ordinary portals regardless of `z-index`, which would hide app toasts and
|
||||
tooltips behind the menu. The shared overlay scale keeps menus below toasts and
|
||||
lets tooltip portals paint above both.
|
||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||
|
||||
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
||||
|
||||
|
||||
+1
-3
@@ -24,7 +24,7 @@ Paseo tools are not implemented as MCP tools internally. They live in a shared t
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
|
||||
@@ -44,8 +44,6 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
|
||||
|
||||
Submitted user-message rows preserve both identities: `messageId` is the provider-visible ID and the optional `clientMessageId` is the Paseo ID from `AgentRunOptions`. Attach `clientMessageId` only to the canonical row for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
@@ -107,6 +107,6 @@ Codex also receives the Windows equivalent:
|
||||
if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex <event>) else (paseo hooks codex <event>))
|
||||
```
|
||||
|
||||
The daemon resolves the current CLI through `PASEO_CLI` when its launcher supplies one, or through the npm package shim for standalone installs. Terminal setup exposes that resolved executable to hooks as `PASEO_HOOK_CLI`; desktop and other daemon launchers do not know about the hook-specific variable. The generated command falls back to bare `paseo` if the hook env is missing and no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the resolved CLI directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
|
||||
Paseo injects `PASEO_HOOK_CLI` so Codex's hook shell cannot pick up a stale global `paseo` before the current one. The command still falls back to bare `paseo` if the env is missing, and it still no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the CLI binary directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
|
||||
|
||||
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.
|
||||
|
||||
@@ -156,6 +156,15 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
|
||||
|
||||
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
|
||||
|
||||
Desktop import coverage starts from the real product Integrations row and crosses the real Electron
|
||||
main process, compiled sandboxed preload, production import IPC, the installed import library, and a
|
||||
real Desktop-managed daemon. It covers confirmation, streamed output, success, failure, and
|
||||
ineligible-host UI:
|
||||
|
||||
```bash
|
||||
npm run test:import-electron --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
|
||||
|
||||
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.
|
||||
|
||||
+8
-21
@@ -51,27 +51,17 @@ When a client resumes with a known cursor, it catches up after that cursor to co
|
||||
|
||||
When a client resumes without a cursor, it fetches the latest tail page.
|
||||
|
||||
## Client replica lifetime
|
||||
|
||||
The host runtime owns each session replica for as long as the host remains registered. React
|
||||
providers attach message handlers and UI integrations to that replica, but mounting or unmounting a
|
||||
provider must not create or clear it. A provider can remount during Fast Refresh or ordinary UI
|
||||
recomposition while the runtime still owns the same directory snapshot and timeline cursors.
|
||||
|
||||
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||
session and host-scoped setup state together.
|
||||
|
||||
## Selective and legacy delivery
|
||||
|
||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||
|
||||
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
||||
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
||||
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||
subscribed for a short grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
|
||||
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
|
||||
After grace has expired, revisiting a retained timeline displays its cached state immediately and
|
||||
authoritative catch-up advances it to the current tail.
|
||||
After grace has expired, a retained timeline stays covered when revisited until authoritative
|
||||
catch-up completes; cached partial output is never presented as current history.
|
||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||
|
||||
@@ -87,14 +77,11 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap
|
||||
remaining page through the existing stream reducer. It must not append full projected text to a
|
||||
live prefix.
|
||||
|
||||
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
|
||||
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
|
||||
where the user submitted it. Other canonical rows are applied after the already-present timeline
|
||||
instead of relocating visible user messages around newly fetched history.
|
||||
|
||||
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
|
||||
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
|
||||
limited to the dated compatibility path for daemon timelines created before that field existed.
|
||||
Optimistic user prompts are presentation state rather than canonical history. Incremental catch-up
|
||||
temporarily separates them, applies canonical entries, lets canonical user rows reconcile through
|
||||
the existing optimistic-message rules, then restores any unmatched prompts after the caught-up
|
||||
history. This keeps late history before a newly submitted prompt without duplicating an
|
||||
acknowledged prompt.
|
||||
|
||||
## Relevant code
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-prXQpWLMwN1JO2tGhGDie/Ud9vnmUSF44581Mm7R7eA=
|
||||
sha256-MjAAaU+zU3m5fdE9rQrr4aA3eTDZztIXJQVXKbj73rY=
|
||||
|
||||
+5
-8
@@ -5,7 +5,6 @@
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
autoPatchelfHook,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
||||
@@ -60,13 +59,10 @@ buildNpmPackage rec {
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty compilation)
|
||||
makeWrapper
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||
autoPatchelfHook
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
libuv
|
||||
stdenv.cc.cc.lib # libstdc++ for sherpa-onnx prebuilt binaries
|
||||
];
|
||||
|
||||
# Don't use the default npm build hook — we need a custom build sequence
|
||||
@@ -75,9 +71,10 @@ buildNpmPackage rec {
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Rebuild only node-pty (native addon for terminal emulation). The sherpa
|
||||
# speech runtime ships prebuilt platform packages and is copied into the
|
||||
# daemon closure by scripts/trace-daemon.mjs.
|
||||
# Rebuild only node-pty (native addon for terminal emulation).
|
||||
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
|
||||
# intentionally left unbuilt — they're lazily loaded and gracefully
|
||||
# degrade when unavailable.
|
||||
npm rebuild node-pty
|
||||
|
||||
# Build all server packages in dependency order (defined in package.json)
|
||||
@@ -92,7 +89,7 @@ buildNpmPackage rec {
|
||||
|
||||
# Compute the daemon's runtime closure by static module-graph tracing
|
||||
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
|
||||
# forked terminal/speech worker processes) plus an explicit list of non-JS
|
||||
# forked terminal-worker-process.js) plus an explicit list of non-JS
|
||||
# assets read at runtime. The trace script is the single source of
|
||||
# truth for what the daemon needs at $out — auditable in plain JS, no
|
||||
# npm hoisting / .bin / workspace-symlink footguns.
|
||||
|
||||
Generated
+62
-88
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -2531,35 +2531,10 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
|
||||
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -2579,33 +2554,22 @@
|
||||
"@codemirror/language": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
|
||||
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
|
||||
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
|
||||
"version": "6.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
||||
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
@@ -11091,19 +11055,6 @@
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@replit/codemirror-vim": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
|
||||
"integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@codemirror/commands": "6.x.x",
|
||||
"@codemirror/language": "6.x.x",
|
||||
"@codemirror/search": "6.x.x",
|
||||
"@codemirror/state": "6.x.x",
|
||||
"@codemirror/view": "6.x.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
@@ -31775,7 +31726,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
|
||||
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -31940,6 +31890,12 @@
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/sql.js": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
|
||||
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/srvx": {
|
||||
"version": "0.11.15",
|
||||
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
|
||||
@@ -35211,13 +35167,8 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -35232,7 +35183,6 @@
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
@@ -36236,12 +36186,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.2.0-beta.2",
|
||||
"@getpaseo/protocol": "0.2.0-beta.2",
|
||||
"@getpaseo/server": "0.2.0-beta.2",
|
||||
"@getpaseo/client": "0.2.0-beta.1",
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/server": "0.2.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36487,24 +36437,27 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.2",
|
||||
"@getpaseo/relay": "0.2.0-beta.2",
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/relay": "0.2.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
"@getpaseo/import": "0.1.0-beta.2",
|
||||
"@getpaseo/server": "*",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
@@ -36512,6 +36465,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "41.2.0",
|
||||
@@ -36522,6 +36476,26 @@
|
||||
"wait-on": "8.0.5"
|
||||
}
|
||||
},
|
||||
"packages/desktop/node_modules/@getpaseo/import": {
|
||||
"version": "0.1.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/@getpaseo/import/-/import-0.1.0-beta.2.tgz",
|
||||
"integrity": "sha512-1t+kw6gUQpaz30BLWIwnm252DpdyMANDGq7sRmb0fRz4Eetsz/QAefTRiYUkS/eKVvfDd3avH9HCIU/DzAXCgQ==",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"smol-toml": "^1.6.0",
|
||||
"sql.js": "^1.14.1"
|
||||
},
|
||||
"bin": {
|
||||
"paseo-import": "dist/bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@getpaseo/client": ">=0.2.0-beta.2"
|
||||
}
|
||||
},
|
||||
"packages/desktop/node_modules/@types/node": {
|
||||
"version": "24.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz",
|
||||
@@ -36744,7 +36718,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37640,9 +37614,9 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -37872,7 +37846,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37885,7 +37859,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38103,15 +38077,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.2.0-beta.2",
|
||||
"@getpaseo/highlight": "0.2.0-beta.2",
|
||||
"@getpaseo/protocol": "0.2.0-beta.2",
|
||||
"@getpaseo/relay": "0.2.0-beta.2",
|
||||
"@getpaseo/client": "0.2.0-beta.1",
|
||||
"@getpaseo/highlight": "0.2.0-beta.1",
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/relay": "0.2.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38648,7 +38622,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -132,8 +132,6 @@
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
cpSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
_electron as electron,
|
||||
expect,
|
||||
test,
|
||||
type ElectronApplication,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires the real Electron product runtime");
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
const nodeRequire = createRequire(path.join(repoRoot, "packages", "desktop", "package.json"));
|
||||
const importPackageRoot = path.resolve(path.dirname(nodeRequire.resolve("@getpaseo/import")), "..");
|
||||
let installation: Awaited<ReturnType<typeof createConductorInstallation>>;
|
||||
let electronApp: ElectronApplication;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
installation = await createConductorInstallation();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await electronApp?.close();
|
||||
rmSync(installation.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("imports from the product Integrations row and renders success, failure, and eligibility", async () => {
|
||||
electronApp = await launchProduct();
|
||||
const page = await electronApp.firstWindow();
|
||||
await openIntegrations(page);
|
||||
await expect(page.getByRole("button", { name: "Enable built-in daemon" })).toHaveCount(0);
|
||||
|
||||
await openImport(page);
|
||||
await expect(
|
||||
page.getByText("Paseo will register valid repositories", { exact: false }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("import-confirm").click();
|
||||
await expect(page.getByTestId("import-result")).toHaveText("Import complete.");
|
||||
await expect(page.getByTestId("import-output")).toContainText(
|
||||
`Registered project ${installation.repo}.`,
|
||||
);
|
||||
await expect(page.getByTestId("import-output")).toContainText("Import summary:");
|
||||
|
||||
await page.getByTestId("import-done").click();
|
||||
writeFileSync(installation.databasePath, "not a sqlite database");
|
||||
await openImport(page);
|
||||
await page.getByTestId("import-confirm").click();
|
||||
await expect(page.getByTestId("import-result")).toHaveText("Import failed.");
|
||||
await expect(page.getByTestId("import-output")).toContainText("ERROR:");
|
||||
|
||||
await page.getByTestId("import-done").click();
|
||||
await stopImportHost();
|
||||
await page.getByRole("button", { name: "General", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Integrations", exact: true }).click();
|
||||
const row = page.getByTestId("conductor-import-row");
|
||||
await expect(row).toContainText("Start the Desktop-managed host before importing.");
|
||||
await expect(row.getByRole("button", { name: "Import", exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
async function launchProduct(): Promise<ElectronApplication> {
|
||||
const metroPort = requiredEnvironment("E2E_METRO_PORT");
|
||||
return electron.launch({
|
||||
args: [path.join(repoRoot, "packages", "desktop", "dist", "main.js")],
|
||||
env: {
|
||||
...process.env,
|
||||
EXPO_DEV_URL: `http://127.0.0.1:${metroPort}`,
|
||||
HOME: installation.home,
|
||||
PASEO_HOME: requiredEnvironment("E2E_PASEO_HOME"),
|
||||
PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1",
|
||||
PASEO_TEST_APP_NAME: "Paseo Import Behavior",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openIntegrations(page: Page): Promise<void> {
|
||||
const settings = page.getByRole("button", { name: "Settings", exact: true });
|
||||
await expect(settings).toBeVisible({ timeout: 90_000 });
|
||||
await settings.click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Integrations", exact: true }).click();
|
||||
await expect(page.getByTestId("conductor-import-row")).toBeVisible();
|
||||
}
|
||||
|
||||
async function openImport(page: Page): Promise<void> {
|
||||
const button = page
|
||||
.getByTestId("conductor-import-row")
|
||||
.getByRole("button", { name: "Import", exact: true });
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
await expect(page.getByTestId("import-sheet")).toBeVisible();
|
||||
}
|
||||
|
||||
async function createConductorInstallation(): Promise<{
|
||||
root: string;
|
||||
home: string;
|
||||
repo: string;
|
||||
databasePath: string;
|
||||
}> {
|
||||
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-product-import-")));
|
||||
const home = path.join(root, "home");
|
||||
const repo = path.join(root, "repo");
|
||||
mkdirSync(home, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main", repo]);
|
||||
execFileSync("git", ["config", "user.email", "fixture@example.com"], { cwd: repo });
|
||||
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo });
|
||||
writeFileSync(path.join(repo, "README.md"), "fixture\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repo });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], {
|
||||
cwd: repo,
|
||||
});
|
||||
|
||||
const databaseDirectory = path.join(home, "Library", "Application Support", "com.conductor.app");
|
||||
mkdirSync(databaseDirectory, { recursive: true });
|
||||
const databasePath = path.join(databaseDirectory, "conductor.db");
|
||||
cpSync(path.join(importPackageRoot, "fixtures", "conductor", "conductor.db"), databasePath);
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
`const fs = require("node:fs");
|
||||
const initSqlJs = require("sql.js");
|
||||
(async () => {
|
||||
const SQL = await initSqlJs({ locateFile: () => require.resolve("sql.js/dist/sql-wasm.wasm") });
|
||||
const databasePath = process.argv[1];
|
||||
const repo = process.argv[2];
|
||||
const database = new SQL.Database(fs.readFileSync(databasePath));
|
||||
database.run("UPDATE repos SET root_path = ? WHERE id = 'repo-current'", [repo]);
|
||||
database.run("UPDATE repos SET is_hidden = 1 WHERE id <> 'repo-current'");
|
||||
database.run("DELETE FROM workspaces");
|
||||
fs.writeFileSync(databasePath, database.export());
|
||||
database.close();
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });`,
|
||||
databasePath,
|
||||
repo,
|
||||
],
|
||||
{ cwd: repoRoot },
|
||||
);
|
||||
return { root, home, repo: realpathSync(repo), databasePath };
|
||||
}
|
||||
|
||||
async function stopImportHost(): Promise<void> {
|
||||
const pidFile = path.join(requiredEnvironment("E2E_PASEO_HOME"), "paseo.pid");
|
||||
const pid = (JSON.parse(readFileSync(pidFile, "utf8")) as { pid: number }).pid;
|
||||
process.kill(pid, "SIGTERM");
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(`Import host ${pid} did not stop.`);
|
||||
}
|
||||
|
||||
function requiredEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required.`);
|
||||
return value;
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
const RED_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
const BLUE_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
function editor(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
|
||||
async function replaceEditorText(page: Page, content: string): Promise<void> {
|
||||
const contentElement = editor(page);
|
||||
await contentElement.click();
|
||||
await contentElement.press("Control+A");
|
||||
await contentElement.type(content);
|
||||
}
|
||||
|
||||
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||
if (!(await tree.isVisible())) await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, filename);
|
||||
await expectFileTabOpen(page, filename);
|
||||
}
|
||||
|
||||
async function seedAgentWithFileLink(target: string) {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "file-editing-chat-link-",
|
||||
title: "Chat file link e2e",
|
||||
initialPrompt: [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n"),
|
||||
});
|
||||
await writeFile(
|
||||
path.join(session.cwd, "target.ts"),
|
||||
Array.from({ length: 80 }, (_, index) => `export const line${index + 1} = ${index + 1};`).join(
|
||||
"\n",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink(target);
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
const fileLink = page.getByText(target, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
await expectFileTabOpen(page, "target.ts");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
|
||||
).toBeVisible();
|
||||
|
||||
const sourceEditor = editor(page);
|
||||
await sourceEditor.click();
|
||||
await sourceEditor.press("Control+Home");
|
||||
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(fileLink).toBeVisible();
|
||||
await fileLink.click();
|
||||
|
||||
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the full file path and keeps editor controls stable", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-visuals-" });
|
||||
const relativePath = "src/deep/visuals.md";
|
||||
const sourcePath = path.join(workspace.repoPath, relativePath);
|
||||
await mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
[...Array.from({ length: 11 }, (_, index) => `line ${index + 1}`), "abcdefghijklmnop"].join(
|
||||
"\n",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await page.getByTestId("file-explorer-tree-scroll").getByText("src", { exact: true }).click();
|
||||
await page.getByTestId("file-explorer-tree-scroll").getByText("deep", { exact: true }).click();
|
||||
await openFileFromExplorer(page, "visuals.md");
|
||||
await expectFileTabOpen(page, relativePath);
|
||||
|
||||
const fileTab = page.getByTestId(`workspace-tab-file_${relativePath}`).first();
|
||||
await fileTab.hover();
|
||||
await expect(page.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)).toHaveText(
|
||||
relativePath,
|
||||
);
|
||||
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
|
||||
const modeControl = page.getByTestId("file-markdown-mode");
|
||||
await expect(modeControl).toBeVisible();
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
|
||||
const editorHost = page.getByTestId("file-source-editor");
|
||||
const content = editor(page);
|
||||
await expect(editorHost).toHaveAttribute("data-pmono", "");
|
||||
await expect(content).toHaveCSS("font-family", /SFMono-Regular/);
|
||||
|
||||
await content.click();
|
||||
const cursor = editorHost.locator(".cm-cursor-primary");
|
||||
await expect(cursor).toBeVisible();
|
||||
await expect(cursor).toHaveCSS("border-left-color", "rgb(250, 250, 250)");
|
||||
|
||||
const initialModeBox = await modeControl.boundingBox();
|
||||
expect(initialModeBox).not.toBeNull();
|
||||
const initialModeX = initialModeBox!.x;
|
||||
await content.press("Control+End");
|
||||
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
||||
const movedModeBox = await modeControl.boundingBox();
|
||||
expect(movedModeBox).not.toBeNull();
|
||||
expect(movedModeBox!.x).toBe(initialModeX);
|
||||
|
||||
await content.press("Control+a");
|
||||
const selection = editorHost.locator(".cm-selectionBackground").first();
|
||||
await expect(selection).toBeVisible();
|
||||
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
|
||||
});
|
||||
|
||||
test("applies the interface font to portaled tooltips", async ({ page, withWorkspace }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "monospace" }));
|
||||
});
|
||||
const workspace = await withWorkspace({ prefix: "file-tooltip-font-" });
|
||||
const relativePath = "tooltip-font.txt";
|
||||
await writeFile(path.join(workspace.repoPath, relativePath), "tooltip font\n", "utf8");
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, relativePath);
|
||||
await expectFileTabOpen(page, relativePath);
|
||||
|
||||
await page.getByTestId(`workspace-tab-file_${relativePath}`).first().hover();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)
|
||||
.getByText(relativePath, { exact: true }),
|
||||
).toHaveCSS("font-family", "monospace");
|
||||
});
|
||||
|
||||
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-source-" });
|
||||
const sourcePath = path.join(workspace.repoPath, "source.ts");
|
||||
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||
await Promise.all(
|
||||
["one.ts", "two.ts", "three.ts", "four.ts"].map((fileName) =>
|
||||
writeFile(path.join(workspace.repoPath, fileName), `// ${fileName}\n`, "utf8"),
|
||||
),
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "source.ts");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByLabel(/File size/)).toBeVisible();
|
||||
await expect(page.getByLabel(/lines/)).toBeVisible();
|
||||
|
||||
await replaceEditorText(page, "const autosaved = 2;\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
|
||||
await expect(page.getByLabel("Editor status dirty")).toBeVisible();
|
||||
await expect(page.getByLabel("Editor status clean")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).not.toBeVisible();
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const autosaved = 2;\n");
|
||||
|
||||
await replaceEditorText(page, "const immediate = 3;\n");
|
||||
await editor(page).press("Control+s");
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const immediate = 3;\n");
|
||||
|
||||
await writeFile(sourcePath, "const external = 4;\nconst line = 2;\n", "utf8");
|
||||
await expect(editor(page)).toContainText("const external = 4;");
|
||||
await expect(page.getByLabel("3 lines")).toBeVisible();
|
||||
|
||||
await replaceEditorText(page, "const localWins = 5;\n");
|
||||
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
|
||||
await openWorkspaceFile(page, fileName);
|
||||
}
|
||||
await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
|
||||
await expect(editor(page)).toContainText("const localWins = 5;");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
|
||||
|
||||
await replaceEditorText(page, "const discarded = 7;\n");
|
||||
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(editor(page)).toContainText("const diskWins = 8;");
|
||||
|
||||
const subscriptionCount = gate.getClientRequestCount("fs.file.subscribe.request");
|
||||
await gate.drop();
|
||||
gate.restore();
|
||||
await expect
|
||||
.poll(() => gate.getClientRequestCount("fs.file.subscribe.request"), { timeout: 30_000 })
|
||||
.toBeGreaterThan(subscriptionCount);
|
||||
await writeFile(sourcePath, "const afterReconnect = 9;\n", "utf8");
|
||||
await expect(editor(page)).toContainText("const afterReconnect = 9;");
|
||||
});
|
||||
|
||||
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
|
||||
const sourcePath = path.join(workspace.repoPath, "draft.ts");
|
||||
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "draft.ts");
|
||||
|
||||
await replaceEditorText(page, "const local = 2;\n");
|
||||
await writeFile(sourcePath, "const external = 3;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
|
||||
let closePrompt = "";
|
||||
page.once("dialog", async (dialog) => {
|
||||
closePrompt = dialog.message();
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page
|
||||
.getByTestId("workspace-tab-file_draft.ts")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click({ button: "right" });
|
||||
await page
|
||||
.getByTestId("workspace-tab-context-file_draft.ts-close")
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
expect(closePrompt).toContain("Closing it will discard the draft.");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
|
||||
const markdownPath = path.join(workspace.repoPath, "notes.md");
|
||||
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||
await writeFile(imagePath, RED_PIXEL);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("file-markdown-mode")).toBeVisible();
|
||||
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
||||
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await replaceEditorText(page, "# Saved from source\n");
|
||||
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||
await page.getByTestId("file-mode-preview").click();
|
||||
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = page.getByTestId("workspace-file-pane").locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-vim-" });
|
||||
await writeFile(path.join(workspace.repoPath, "vim.ts"), "const vim = true;\n", "utf8");
|
||||
|
||||
await page.goto("/settings/editor");
|
||||
const toggle = page.getByRole("switch", { name: "Vim keybindings" });
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
await expect(toggle).toBeChecked();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("switch", { name: "Vim keybindings" })).toBeChecked();
|
||||
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "vim.ts");
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
await expect(page.getByLabel("Line 1, column 1")).toBeVisible();
|
||||
await editor(page).click();
|
||||
await editor(page).press("i");
|
||||
await expect(page.getByLabel("Vim mode INSERT")).toBeVisible();
|
||||
await editor(page).press("Escape");
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -328,12 +328,6 @@ interface PairingDaemonClient {
|
||||
|
||||
async function createFakeEditorBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||
let realGhPath = "";
|
||||
try {
|
||||
realGhPath = execSync("which gh").toString().trim();
|
||||
} catch {
|
||||
// The local PR fixture below remains usable without a system gh binary.
|
||||
}
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
@@ -355,70 +349,6 @@ if (recordPath) {
|
||||
await chmod(editorPath, 0o755);
|
||||
}
|
||||
|
||||
const fakeGhPath = path.join(binDir, "gh");
|
||||
const fakeGhSource = `#!/usr/bin/env node
|
||||
const { spawnSync } = require("child_process");
|
||||
const args = process.argv.slice(2);
|
||||
const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git";
|
||||
const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
}).stdout?.trim();
|
||||
|
||||
if (origin === fixtureRemote) {
|
||||
const command = args.slice(0, 2).join(" ");
|
||||
if (command === "auth status") process.exit(0);
|
||||
if (command === "repo view") {
|
||||
process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null }));
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "issue list") {
|
||||
process.stdout.write("[]");
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "pr list" || command === "pr view") {
|
||||
const pr = {
|
||||
number: 1,
|
||||
title: "Use pasted PR as start ref",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||
state: "OPEN",
|
||||
body: null,
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
updatedAt: "2026-01-01T00:00:00Z"
|
||||
};
|
||||
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
data: { repository: { pullRequest: {
|
||||
number: 1,
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
isCrossRepository: false,
|
||||
headRepositoryOwner: { login: "paseo-e2e" },
|
||||
headRepository: {
|
||||
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
|
||||
url: fixtureRemote
|
||||
}
|
||||
} } }
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const realGhPath = ${JSON.stringify(realGhPath)};
|
||||
if (!realGhPath) process.exit(127);
|
||||
const result = spawnSync(realGhPath, args, { stdio: "inherit" });
|
||||
process.exit(result.status ?? 1);
|
||||
`;
|
||||
await writeFile(fakeGhPath, fakeGhSource);
|
||||
await chmod(fakeGhPath, 0o755);
|
||||
|
||||
return binDir;
|
||||
}
|
||||
|
||||
@@ -779,11 +709,13 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_LISTEN:
|
||||
process.env.E2E_DESKTOP_RUNTIME === "1" ? `127.0.0.1:${args.port}` : `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_DESKTOP_MANAGED: "1" } : {}),
|
||||
});
|
||||
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
|
||||
@@ -65,8 +65,6 @@ export interface DesktopBridgeConfig {
|
||||
daemonListen?: string;
|
||||
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
|
||||
hangDaemonStart?: boolean;
|
||||
/** Delay the desktop settings IPC response to exercise startup ordering. */
|
||||
desktopSettingsDelayMs?: number;
|
||||
/**
|
||||
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||
@@ -103,7 +101,6 @@ declare global {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
__desktopDaemonStartRequested?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +129,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
let daemonRunning = true;
|
||||
let currentPid: number | null = cfg.daemonPid ?? null;
|
||||
let startCount = 0;
|
||||
window.__desktopDaemonStartRequested = false;
|
||||
|
||||
function buildDaemonStatus() {
|
||||
return {
|
||||
@@ -149,7 +145,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}
|
||||
|
||||
function startDesktopDaemon() {
|
||||
window.__desktopDaemonStartRequested = true;
|
||||
if (cfg.hangDaemonStart) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
@@ -161,13 +156,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
async function waitForDesktopSettingsResponse() {
|
||||
const delayMs = cfg.desktopSettingsDelayMs ?? 0;
|
||||
if (delayMs > 0) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
const desktopBridge: {
|
||||
platform: string;
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -224,7 +212,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}
|
||||
|
||||
if (command === "get_desktop_settings") {
|
||||
await waitForDesktopSettingsResponse();
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
@@ -290,17 +277,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function waitForDesktopDaemonStartRequest(page: Page): Promise<void> {
|
||||
await page.waitForFunction(() => window.__desktopDaemonStartRequested === true);
|
||||
// Give the startup state two paints to expose any app → splash regression.
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForDirectoryDialog(
|
||||
page: Page,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export function hasGithubAuth(): boolean {
|
||||
@@ -59,12 +59,6 @@ export interface GhDefaultBranchClone {
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalGhPrFixture {
|
||||
pr: GhPrFixture;
|
||||
mainCheckout: GhDefaultBranchClone;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
function gh(args: string[], opts?: { cwd?: string }): string {
|
||||
return execFileSync("gh", args, {
|
||||
cwd: opts?.cwd,
|
||||
@@ -287,57 +281,3 @@ export async function cloneGithubRepoDefaultBranchOnly(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createLocalGithubPrFixture(): Promise<LocalGhPrFixture> {
|
||||
const fixtureRoot = await mkdtemp(path.join("/tmp", "paseo-e2e-local-github-pr-"));
|
||||
const basePath = path.join(fixtureRoot, "base");
|
||||
const remotePath = path.join(fixtureRoot, "remote.git");
|
||||
const checkoutPath = path.join(fixtureRoot, "main-only");
|
||||
const githubUrl = "https://github.com/paseo-e2e/local-fixture.git";
|
||||
await mkdir(basePath);
|
||||
|
||||
git(["init", "-b", "main"], basePath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], basePath);
|
||||
git(["config", "user.name", "Paseo E2E"], basePath);
|
||||
git(["config", "commit.gpgsign", "false"], basePath);
|
||||
await writeFile(path.join(basePath, "README.md"), "# Local GitHub fixture\n");
|
||||
git(["add", "README.md"], basePath);
|
||||
git(["commit", "-m", "Initial commit"], basePath);
|
||||
git(["checkout", "-b", "pr-branch-1"], basePath);
|
||||
await writeFile(path.join(basePath, "pr-1.txt"), "PR 1\n");
|
||||
git(["add", "pr-1.txt"], basePath);
|
||||
git(["commit", "-m", "Add PR 1"], basePath);
|
||||
git(["checkout", "main"], basePath);
|
||||
|
||||
execFileSync("git", ["clone", "--quiet", "--bare", basePath, remotePath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
git(["update-ref", "refs/pull/1/head", "refs/heads/pr-branch-1"], remotePath);
|
||||
execFileSync(
|
||||
"git",
|
||||
["clone", "--quiet", "--single-branch", "--branch", "main", remotePath, checkoutPath],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
git(["remote", "set-url", "origin", githubUrl], checkoutPath);
|
||||
git(["config", `url.${remotePath}.insteadOf`, githubUrl], checkoutPath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], checkoutPath);
|
||||
git(["config", "user.name", "Paseo E2E"], checkoutPath);
|
||||
git(["config", "commit.gpgsign", "false"], checkoutPath);
|
||||
|
||||
return {
|
||||
pr: {
|
||||
number: 1,
|
||||
title: "Use pasted PR as start ref",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||
branch: "pr-branch-1",
|
||||
localPath: basePath,
|
||||
},
|
||||
mainCheckout: {
|
||||
path: checkoutPath,
|
||||
cleanup: async () => {},
|
||||
},
|
||||
cleanup: async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
@@ -290,13 +290,6 @@ export async function selectBranchInPicker(page: Page, name: string): Promise<vo
|
||||
await branchRow.click();
|
||||
}
|
||||
|
||||
export async function searchAndSelectBranchInPicker(page: Page, name: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder("Search branches and PRs");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await searchInput.fill(name);
|
||||
await selectBranchInPicker(page, name);
|
||||
}
|
||||
|
||||
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
||||
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||
@@ -358,18 +351,6 @@ export async function expectComposerGithubAttachmentPill(
|
||||
await expect(pills.first()).toContainText(input.title);
|
||||
}
|
||||
|
||||
export async function pasteGithubPrUrl(
|
||||
page: Page,
|
||||
context: BrowserContext,
|
||||
url: string,
|
||||
): Promise<void> {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.evaluate((value) => navigator.clipboard.writeText(value), url);
|
||||
await composer.focus();
|
||||
await page.keyboard.press("Control+V");
|
||||
}
|
||||
|
||||
export async function assertNewWorkspaceSidebarAndHeader(
|
||||
page: Page,
|
||||
input: {
|
||||
|
||||
@@ -22,7 +22,6 @@ interface SavedSettingsHostInput {
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
appearance: "Appearance",
|
||||
editor: "Editor",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
|
||||
@@ -7,10 +7,6 @@ interface SessionMessage {
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
interface TimelineSubscriptionWaitOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
@@ -40,16 +36,72 @@ export function observeTimelineSubscriptions(page: Page) {
|
||||
});
|
||||
|
||||
return {
|
||||
async waitForSubscribedAgents(
|
||||
agentIds: string[],
|
||||
options: TimelineSubscriptionWaitOptions = {},
|
||||
): Promise<void> {
|
||||
async waitForSubscribedAgents(agentIds: string[]): Promise<void> {
|
||||
const expected = [...new Set(agentIds)].sort();
|
||||
await expect
|
||||
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, {
|
||||
timeout: options.timeout ?? 15_000,
|
||||
})
|
||||
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { timeout: 15_000 })
|
||||
.toEqual(expected);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface AssistantFrameState {
|
||||
active: boolean;
|
||||
lastText: string | null;
|
||||
snapshots: string[];
|
||||
}
|
||||
|
||||
export async function observeLastAssistantFrames(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const state: AssistantFrameState = {
|
||||
active: true,
|
||||
lastText: null,
|
||||
snapshots: [],
|
||||
};
|
||||
const sample = () => {
|
||||
const hasPaintedHistoryOverlay = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-history-overlay"]'),
|
||||
).some((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
if (!hasPaintedHistoryOverlay) {
|
||||
const messages = Array.from(
|
||||
document.querySelectorAll('[data-testid="assistant-message"]'),
|
||||
).filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
const text = messages.at(-1)?.textContent?.trim() ?? "";
|
||||
if (text && text !== state.lastText) {
|
||||
state.lastText = text;
|
||||
state.snapshots.push(text);
|
||||
}
|
||||
}
|
||||
if (state.active) requestAnimationFrame(sample);
|
||||
};
|
||||
Object.assign(window, { __assistantFrameState: state });
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
|
||||
return {
|
||||
async stop(): Promise<string[]> {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
new Promise<string[]>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
const state = (
|
||||
window as typeof window & { __assistantFrameState?: AssistantFrameState }
|
||||
).__assistantFrameState;
|
||||
if (!state) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
state.active = false;
|
||||
resolve([...state.snapshots]);
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,14 +17,11 @@ import {
|
||||
expectPickerOpen,
|
||||
expectPickerSelected,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
fillNewWorkspaceDraft,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openBranchPicker,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
pasteGithubPrUrl,
|
||||
searchAndSelectBranchInPicker,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
@@ -33,11 +30,9 @@ import {
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import {
|
||||
createLocalGithubPrFixture,
|
||||
cloneGithubRepoDefaultBranchOnly,
|
||||
createTempGithubRepo,
|
||||
hasGithubAuth,
|
||||
type LocalGhPrFixture,
|
||||
} from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
@@ -196,7 +191,6 @@ test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
const localGithubFixtures = new Set<LocalGhPrFixture>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
@@ -218,13 +212,6 @@ test.describe("New workspace flow", () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
for (const fixture of localGithubFixtures) {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
localGithubFixtures.clear();
|
||||
});
|
||||
|
||||
test("adds a project from the selected empty host", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("new-workspace-project-picker-");
|
||||
const primaryServerId = getServerId();
|
||||
@@ -820,100 +807,6 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("pasted GitHub PR replaces a selected branch and creates its worktree", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const fixture = await createLocalGithubPrFixture();
|
||||
localGithubFixtures.add(fixture);
|
||||
const { pr, mainCheckout } = fixture;
|
||||
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "main");
|
||||
|
||||
await pasteGithubPrUrl(page, context, pr.url);
|
||||
|
||||
await expect(page.getByTestId("workspace-create-submit")).toBeDisabled();
|
||||
await expectComposerGithubAttachmentPill(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
});
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
headRef: pr.branch,
|
||||
});
|
||||
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const branchInfo = await readWorktreeBranchInfo({
|
||||
worktreePath: worktree.workspaceDirectory,
|
||||
});
|
||||
expect(branchInfo.currentBranch).toBe(pr.branch);
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
test("branches remain searchable after a pasted PR and determine the created worktree", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const fixture = await createLocalGithubPrFixture();
|
||||
localGithubFixtures.add(fixture);
|
||||
const { pr, mainCheckout } = fixture;
|
||||
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await pasteGithubPrUrl(page, context, pr.url);
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
headRef: pr.branch,
|
||||
});
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
await searchAndSelectBranchInPicker(page, "main");
|
||||
await expectPickerSelected(page, "main");
|
||||
await fillNewWorkspaceDraft(page, `${pr.url}\nKeep this checkout on main`);
|
||||
await expectPickerSelected(page, "main");
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -219,7 +219,6 @@ test.describe("Half-screen desktop layout", () => {
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
|
||||
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
||||
|
||||
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
|
||||
@@ -3,7 +3,10 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { test } from "./fixtures";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { observeTimelineSubscriptions } from "./helpers/timeline-delivery";
|
||||
import {
|
||||
observeLastAssistantFrames,
|
||||
observeTimelineSubscriptions,
|
||||
} from "./helpers/timeline-delivery";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import {
|
||||
@@ -72,40 +75,47 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string,
|
||||
}
|
||||
|
||||
test.describe("Viewed agent timelines", () => {
|
||||
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
|
||||
test("a focused turn streams live and resumes its hidden remainder atomically", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
||||
await commitMessage(
|
||||
scenario,
|
||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId]);
|
||||
|
||||
await scenario.client.sendAgentMessage(
|
||||
scenario.firstAgentId,
|
||||
"Committed after the first chat unsubscribed.",
|
||||
"Stream while focused, then finish while hidden.",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expect(
|
||||
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||
page.getByTestId("assistant-message").filter({ hasText: "Cycle 1" }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
|
||||
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId]);
|
||||
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
|
||||
const assistantFrames = await observeLastAssistantFrames(page);
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
const snapshots = await assistantFrames.stop();
|
||||
|
||||
expect(snapshots[0]).toContain("(end of synthetic stream)");
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
test("a hidden retained chat catches up when shown", async ({ page }) => {
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
|
||||
await commitMessage(
|
||||
scenario,
|
||||
scenario.firstAgentId,
|
||||
@@ -118,7 +128,6 @@ test.describe("Viewed agent timelines", () => {
|
||||
await expect(
|
||||
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
|
||||
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
@@ -44,49 +43,6 @@ async function fetchActiveAgentTitle(
|
||||
}
|
||||
|
||||
test.describe("Workspace agent title handoff", () => {
|
||||
test("does not cover the agent pane while the optimistic create becomes authoritative", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
const timelineGate = await delayCreatedAgentInitialTailResponse(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-create-handoff-flash-" });
|
||||
|
||||
try {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const prompt = "Keep the optimistic agent pane visible during handoff";
|
||||
await submitMessage(page, prompt);
|
||||
const agentId = await timelineGate.waitForCreatedAgent();
|
||||
await timelineGate.waitForDelayedResponse();
|
||||
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("agent-history-overlay")).toHaveCount(0);
|
||||
|
||||
const overlayAppeared = page
|
||||
.getByTestId("agent-history-overlay")
|
||||
.waitFor({ state: "attached", timeout: 2_000 })
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
timelineGate.release();
|
||||
await timelineGate.waitForForwardedResponse();
|
||||
|
||||
expect(await overlayAppeared).toBe(false);
|
||||
} finally {
|
||||
timelineGate.release();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the prompt tab title and replaces it when the daemon title updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -31,52 +31,11 @@ import {
|
||||
} from "./helpers/workspace-ui";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
|
||||
import { injectDesktopBridge } from "./helpers/desktop-updates";
|
||||
import { expectAppRoute } from "./helpers/route-assertions";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
|
||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||
type StartupPresentation = "splash" | "app";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__paseoStartupPresentationTrace?: StartupPresentation[];
|
||||
}
|
||||
}
|
||||
|
||||
async function observeStartupPresentation(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const trace: StartupPresentation[] = [];
|
||||
window.__paseoStartupPresentationTrace = trace;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const recordPresentation = () => {
|
||||
let presentation: StartupPresentation | null = null;
|
||||
if (document.querySelector('[data-testid="startup-splash"]')) {
|
||||
presentation = "splash";
|
||||
} else if (
|
||||
document.querySelector(
|
||||
'[data-testid="workspace-header-title"], [data-testid="sidebar-settings"]',
|
||||
)
|
||||
) {
|
||||
presentation = "app";
|
||||
}
|
||||
if (presentation && trace.at(-1) !== presentation) {
|
||||
trace.push(presentation);
|
||||
}
|
||||
};
|
||||
new MutationObserver(recordPresentation).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
recordPresentation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getStartupPresentation(page: Page): Promise<StartupPresentation[]> {
|
||||
return page.evaluate(() => window.__paseoStartupPresentationTrace?.slice() ?? []);
|
||||
}
|
||||
|
||||
async function expectNoLoadingWorkspacePane(
|
||||
page: Page,
|
||||
@@ -280,9 +239,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("refresh keeps one continuous splash before restoring the workspace route", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("refresh keeps the user on the same workspace route", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const daemonGate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
|
||||
@@ -297,7 +254,6 @@ test.describe("Workspace navigation regression", () => {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
hangDaemonStart: true,
|
||||
desktopSettingsDelayMs: 250,
|
||||
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
});
|
||||
await openWorkspaceThroughApp(page, { serverId, workspace });
|
||||
@@ -305,16 +261,14 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||
|
||||
await observeStartupPresentation(page);
|
||||
await daemonGate.drop();
|
||||
await page.reload();
|
||||
await waitForDesktopDaemonStartRequest(page);
|
||||
await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 });
|
||||
daemonGate.restore();
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
expect(await getStartupPresentation(page)).toEqual(["splash", "app"]);
|
||||
} finally {
|
||||
daemonGate.restore();
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
|
||||
"test:e2e:import-electron": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/conductor-import.electron.spec.ts",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
@@ -32,11 +33,6 @@
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -51,7 +47,6 @@
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
|
||||
@@ -27,7 +27,10 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "Desktop Chrome",
|
||||
testIgnore: ["**/*.real.spec.ts"],
|
||||
testIgnore:
|
||||
process.env.E2E_DESKTOP_RUNTIME === "1"
|
||||
? ["**/*.real.spec.ts"]
|
||||
: ["**/*.real.spec.ts", "**/*.electron.spec.ts"],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
Platform,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItemInfo,
|
||||
@@ -190,12 +189,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
},
|
||||
scrollToBottom,
|
||||
});
|
||||
// Android's maintainVisibleContentPosition ignores the list inversion transform and
|
||||
// fights the controller's offset-zero correction while the live header grows.
|
||||
const maintainVisibleContentPosition =
|
||||
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
|
||||
? undefined
|
||||
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
|
||||
|
||||
useEffect(() => {
|
||||
streamViewportMetricsRef.current = {
|
||||
@@ -421,7 +414,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
||||
initialNumToRender={40}
|
||||
maxToRenderPerBatch={40}
|
||||
updateCellsBatchingPeriod={0}
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startDaemonIfGateAllows,
|
||||
startHostRuntimeBootstrap,
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
@@ -336,6 +337,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -374,7 +376,11 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
|
||||
startDaemonIfGateAllows({
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const splashError =
|
||||
|
||||
@@ -458,6 +458,8 @@ export interface AdaptiveModalSheetProps {
|
||||
desktopMaxWidth?: number;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
/** Whether gestures may dismiss the compact bottom sheet. */
|
||||
dismissible?: boolean;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -472,6 +474,7 @@ export function AdaptiveModalSheet({
|
||||
desktopMaxWidth,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
dismissible = true,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -608,7 +611,7 @@ export function AdaptiveModalSheet({
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
enablePanDownToClose={dismissible}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
keyboardBehavior="extend"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
MoreVertical,
|
||||
RotateCw,
|
||||
} from "lucide-react-native";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
@@ -153,7 +154,7 @@ function TreeRowItem({
|
||||
<View style={styles.entryIcon}>
|
||||
{(() => {
|
||||
if (!isDirectory) {
|
||||
return <MaterialFileIcon fileName={entry.name} size={16} />;
|
||||
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
||||
}
|
||||
if (loading) return <ActivityIndicator size="small" />;
|
||||
return <TreeChevron expanded={isExpanded} />;
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { FileVersion } from "@getpaseo/protocol/messages";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
@@ -15,7 +8,7 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -36,14 +29,6 @@ import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useLiveFile } from "./live-file";
|
||||
import { FilePanelBar } from "./bar";
|
||||
import { FileEditorModel, type FileEditorFile } from "./editor/model";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
@@ -57,12 +42,9 @@ interface FilePreviewBodyProps {
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
imagePreviewUri: string | null;
|
||||
}
|
||||
|
||||
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -208,10 +190,9 @@ function FilePreviewBody({
|
||||
isLoading,
|
||||
isMobile,
|
||||
location,
|
||||
navigationRevision,
|
||||
imagePreviewUri,
|
||||
}: FilePreviewBodyProps) {
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
const isMarkdownFile =
|
||||
@@ -259,7 +240,7 @@ function FilePreviewBody({
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [lineHeight, lineSelection, navigationRevision]);
|
||||
}, [lineHeight, lineSelection]);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
@@ -382,27 +363,15 @@ export function FilePane({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const [markdownMode, setMarkdownMode] = useState<"preview" | "source">("preview");
|
||||
const [resolvedPreview, setResolvedPreview] = useState<{
|
||||
key: string | null;
|
||||
file: ExplorerFile | null;
|
||||
imageAttachment: AttachmentMetadata | null;
|
||||
}>({ key: null, file: null, imageAttachment: null });
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||
const supportsEditing = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||
const readTarget = useMemo(
|
||||
@@ -421,359 +390,61 @@ export function FilePane({
|
||||
// from another window after an external edit. The gate lives in isFileQueryEnabled.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isAppVisible = useAppActivelyVisible();
|
||||
const enabled = isFileQueryEnabled({
|
||||
hasReadTarget: Boolean(client && readTarget),
|
||||
isTabActive: isActive,
|
||||
isAppVisible,
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
|
||||
enabled: isFileQueryEnabled({
|
||||
hasReadTarget: Boolean(client && readTarget),
|
||||
isTabActive: isActive,
|
||||
isAppVisible,
|
||||
}),
|
||||
queryFn: async () => {
|
||||
if (!client || !readTarget) {
|
||||
return {
|
||||
file: null as ExplorerFile | null,
|
||||
error: t("workspace.terminal.hostDisconnected"),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const file = await client.readFile(readTarget.cwd, readTarget.path);
|
||||
const preview = await createFilePanePreview(file);
|
||||
return {
|
||||
file: preview.file,
|
||||
imageAttachment: preview.imageAttachment,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
file: null,
|
||||
imageAttachment: null,
|
||||
error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
|
||||
};
|
||||
}
|
||||
},
|
||||
staleTime: 5_000,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
const { query, version } = useLiveFile({
|
||||
client,
|
||||
serverId,
|
||||
cwd: readTarget?.cwd ?? null,
|
||||
path: readTarget?.path ?? null,
|
||||
enabled,
|
||||
liveUpdates: supportsEditing,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
||||
void (async () => {
|
||||
const nextPreview = await createFilePanePreview(query.data ?? null);
|
||||
if (active) setResolvedPreview({ key, ...nextPreview });
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [query.data, readTarget]);
|
||||
|
||||
useEffect(() => setMarkdownMode("preview"), [readTarget?.path]);
|
||||
|
||||
const previewKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
||||
const preview = resolvedPreview.key === previewKey ? resolvedPreview.file : null;
|
||||
const imagePreviewUri = useAttachmentPreviewUrl(
|
||||
resolvedPreview.key === previewKey ? resolvedPreview.imageAttachment : null,
|
||||
);
|
||||
const isMarkdown = isMarkdownPreview(preview, location.path);
|
||||
const editable = isEditableTextFile({
|
||||
preview,
|
||||
supportsEditing,
|
||||
});
|
||||
const lineCount =
|
||||
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
|
||||
const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad"));
|
||||
|
||||
return (
|
||||
<FilePanePresentation
|
||||
serverId={serverId}
|
||||
client={client}
|
||||
readTarget={readTarget}
|
||||
preview={preview}
|
||||
version={version}
|
||||
filename={getFileNameFromPath(location.path) ?? location.path}
|
||||
markdownMode={isMarkdown ? markdownMode : undefined}
|
||||
onMarkdownModeChange={isMarkdown ? setMarkdownMode : undefined}
|
||||
lineCount={lineCount}
|
||||
editable={editable}
|
||||
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
|
||||
errorMessage={errorMessage}
|
||||
isLoading={query.isFetching}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function isMarkdownPreview(preview: ExplorerFile | null, path: string): boolean {
|
||||
return preview?.kind === "text" && isRenderedMarkdownFile(path);
|
||||
}
|
||||
|
||||
function getFileErrorMessage(error: unknown, fallback: string): string | null {
|
||||
if (!error) return null;
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function isEditableTextFile(input: {
|
||||
preview: ExplorerFile | null;
|
||||
supportsEditing: boolean;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
isWeb &&
|
||||
input.supportsEditing &&
|
||||
input.preview?.kind === "text" &&
|
||||
input.preview.size <= 1024 * 1024,
|
||||
);
|
||||
}
|
||||
|
||||
function FilePanePresentation({
|
||||
serverId,
|
||||
client,
|
||||
readTarget,
|
||||
preview,
|
||||
version,
|
||||
filename,
|
||||
markdownMode,
|
||||
onMarkdownModeChange,
|
||||
lineCount,
|
||||
editable,
|
||||
disconnectedMessage,
|
||||
errorMessage,
|
||||
isLoading,
|
||||
isMobile,
|
||||
location,
|
||||
navigationRevision,
|
||||
imagePreviewUri,
|
||||
}: {
|
||||
serverId: string;
|
||||
client: DaemonClient | null;
|
||||
readTarget: { cwd: string; path: string } | null;
|
||||
preview: ExplorerFile | null;
|
||||
version: FileVersion | null;
|
||||
filename: string;
|
||||
markdownMode?: "preview" | "source";
|
||||
onMarkdownModeChange?: (mode: "preview" | "source") => void;
|
||||
lineCount?: number;
|
||||
editable: boolean;
|
||||
disconnectedMessage: string;
|
||||
errorMessage: string | null;
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
imagePreviewUri: string | null;
|
||||
}) {
|
||||
if (!client && readTarget) {
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{disconnectedMessage}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (editable && client && readTarget && preview?.kind === "text") {
|
||||
return (
|
||||
<EditableFilePane
|
||||
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
|
||||
client={client}
|
||||
cwd={readTarget.cwd}
|
||||
path={readTarget.path}
|
||||
preview={preview as TextExplorerFile}
|
||||
version={version}
|
||||
filename={filename}
|
||||
mode={markdownMode}
|
||||
onModeChange={onMarkdownModeChange}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
{preview ? (
|
||||
<FilePanelBar
|
||||
size={preview.size}
|
||||
lineCount={lineCount}
|
||||
mode={markdownMode}
|
||||
onModeChange={onMarkdownModeChange}
|
||||
/>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
{query.data?.error ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
<Text style={styles.errorText}>{query.data.error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<FilePreviewBody
|
||||
preview={preview}
|
||||
isLoading={isLoading}
|
||||
preview={query.data?.file ?? null}
|
||||
isLoading={query.isFetching}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableFilePane({
|
||||
client,
|
||||
cwd,
|
||||
path,
|
||||
preview,
|
||||
version,
|
||||
filename,
|
||||
mode,
|
||||
onModeChange,
|
||||
isLoading,
|
||||
isMobile,
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
client: DaemonClient;
|
||||
cwd: string;
|
||||
path: string;
|
||||
preview: TextExplorerFile;
|
||||
version: FileVersion | null;
|
||||
filename: string;
|
||||
mode?: "preview" | "source";
|
||||
onModeChange?: (mode: "preview" | "source") => void;
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const { settings } = useAppSettings();
|
||||
const { t } = useTranslation();
|
||||
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
||||
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
|
||||
const session = useMemo(
|
||||
() => ({
|
||||
async read(): Promise<FileEditorFile> {
|
||||
const file = await client.readFile(cwd, path);
|
||||
if (file.kind !== "text") throw new Error("File is no longer text.");
|
||||
return {
|
||||
content: new TextDecoder().decode(file.bytes),
|
||||
version: {
|
||||
status: "ready",
|
||||
cwd,
|
||||
path,
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
revision: file.revision,
|
||||
},
|
||||
};
|
||||
},
|
||||
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
|
||||
return client.writeFile({ cwd, path, ...input });
|
||||
},
|
||||
}),
|
||||
[client, cwd, path],
|
||||
);
|
||||
const [model] = useState(
|
||||
() =>
|
||||
new FileEditorModel({
|
||||
file: {
|
||||
content: preview.content ?? "",
|
||||
version: {
|
||||
status: "ready",
|
||||
cwd,
|
||||
path,
|
||||
size: preview.size,
|
||||
modifiedAt: preview.modifiedAt,
|
||||
},
|
||||
},
|
||||
session,
|
||||
}),
|
||||
);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
|
||||
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
|
||||
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.colors.border,
|
||||
theme.colors.foreground,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.colors.surface0,
|
||||
theme.colors.syntax,
|
||||
theme.colors.terminal.cursor,
|
||||
theme.colors.terminal.selectionBackground,
|
||||
theme.colorScheme,
|
||||
theme.fontFamily.mono,
|
||||
theme.fontSize.code,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => () => model.dispose(), [model]);
|
||||
useEffect(() => {
|
||||
if (version) model.receiveFileVersion(version);
|
||||
}, [model, version]);
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("panels.file.editor.reloadTitle"),
|
||||
message: t("panels.file.editor.reloadMessage"),
|
||||
confirmLabel: t("panels.file.editor.reload"),
|
||||
destructive: true,
|
||||
});
|
||||
if (confirmed) void model.reload();
|
||||
})();
|
||||
}, [model, t]);
|
||||
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
|
||||
const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []);
|
||||
const renderedPreview = useMemo<ExplorerFile>(
|
||||
() => ({
|
||||
...preview,
|
||||
content: snapshot.content,
|
||||
size: snapshot.version.status === "ready" ? snapshot.version.size : preview.size,
|
||||
modifiedAt:
|
||||
snapshot.version.status === "ready" ? snapshot.version.modifiedAt : preview.modifiedAt,
|
||||
}),
|
||||
[preview, snapshot.content, snapshot.version],
|
||||
);
|
||||
const showSource = mode !== "preview";
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
<FilePanelBar
|
||||
size={
|
||||
snapshot.observedVersion.status === "ready" ? snapshot.observedVersion.size : preview.size
|
||||
}
|
||||
lineCount={snapshot.content.split("\n").length}
|
||||
editorStatus={snapshot.status}
|
||||
cursor={showSource ? cursor : undefined}
|
||||
vimMode={showSource ? vimMode : null}
|
||||
conflictUnavailable={snapshot.observedVersion.status !== "ready"}
|
||||
onOverwrite={handleOverwrite}
|
||||
onReload={handleReload}
|
||||
mode={mode}
|
||||
onModeChange={onModeChange}
|
||||
/>
|
||||
{showSource ? (
|
||||
<FileEditorView
|
||||
model={model}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
vimEnabled={settings.vimKeybindings}
|
||||
theme={visualTheme}
|
||||
onCursorChange={setCursor}
|
||||
onVimModeChange={handleVimModeChange}
|
||||
/>
|
||||
) : (
|
||||
<FilePreviewBody
|
||||
preview={renderedPreview}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={null}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -341,64 +341,6 @@ function FooterIconButton({
|
||||
);
|
||||
}
|
||||
|
||||
function footerAddProjectButtonStyle({
|
||||
hovered,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered];
|
||||
}
|
||||
|
||||
function FooterAddProjectButton({
|
||||
onPress,
|
||||
label,
|
||||
shortcutKeys,
|
||||
theme,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
label: string;
|
||||
shortcutKeys: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={footerAddProjectButtonStyle}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
accessible
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const isHovered = Boolean(hovered);
|
||||
return (
|
||||
<>
|
||||
<FolderPlus
|
||||
size={theme.iconSize.sm}
|
||||
color={isHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.footerAddProjectLabel,
|
||||
isHovered && styles.footerAddProjectLabelHovered,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHostPicker({
|
||||
theme,
|
||||
label,
|
||||
@@ -552,12 +494,6 @@ function SidebarFooter({
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarFooter}>
|
||||
<FooterAddProjectButton
|
||||
onPress={handleOpenProject}
|
||||
label={labels.addProject}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<View style={styles.footerIconRow}>
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
@@ -565,6 +501,14 @@ function SidebarFooter({
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleHome}
|
||||
testID="sidebar-home"
|
||||
@@ -980,6 +924,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarHeaderGroup: {
|
||||
paddingTop: theme.spacing[2],
|
||||
gap: 2,
|
||||
// Distance from History's bottom edge to the divider. WorkspacesSectionHeader
|
||||
// uses a slightly smaller paddingTop to balance the action buttons' centering
|
||||
// offset so the divider reads as visually centered between the two.
|
||||
paddingBottom: theme.spacing[1.5],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
@@ -1061,8 +1008,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
justifyContent: "flex-start",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
@@ -1073,30 +1020,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
footerAddProjectButton: {
|
||||
minWidth: 0,
|
||||
minHeight: 32,
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
footerAddProjectButtonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
footerAddProjectLabel: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
footerAddProjectLabelHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
footerIconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -569,26 +569,8 @@ export function createSharedMarkdownRules(): RenderRules {
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownTextSpan key={node.key} style={styles.hardbreak}>
|
||||
{"\n"}
|
||||
</MarkdownTextSpan>
|
||||
),
|
||||
softbreak: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownTextSpan key={node.key} style={styles.softbreak}>
|
||||
{"\n"}
|
||||
</MarkdownTextSpan>
|
||||
),
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
|
||||
export function MaterialFileIcon({ fileName, size }: { fileName: string; size: number }) {
|
||||
return <SvgXml xml={getFileIconSvg(fileName)} width={size} height={size} />;
|
||||
}
|
||||
|
||||
export function createMaterialFileIcon(fileName: string): ComponentType<PanelIconProps> {
|
||||
function BoundMaterialFileIcon({ size }: PanelIconProps) {
|
||||
return <MaterialFileIcon fileName={fileName} size={size} />;
|
||||
}
|
||||
return BoundMaterialFileIcon;
|
||||
}
|
||||
@@ -1689,29 +1689,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
// plain <Text> is not hoisted into a UITextViewChild and is dropped (same
|
||||
// root cause as strong/em/s) — so on iOS a hard line break vanished, and
|
||||
// a softbreak between words jammed them together ("one\ntwo" -> "onetwo").
|
||||
// Emit the break through MarkdownTextSpan so it composes on iOS. Keep
|
||||
// the resolved break styles: hardbreak is a full-width flex-row child on
|
||||
// Android, and dropping that width joins the surrounding text spans.
|
||||
hardbreak: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownTextSpan key={node.key} style={styles.hardbreak}>
|
||||
{"\n"}
|
||||
</MarkdownTextSpan>
|
||||
),
|
||||
softbreak: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownTextSpan key={node.key} style={styles.softbreak}>
|
||||
{"\n"}
|
||||
</MarkdownTextSpan>
|
||||
),
|
||||
// Emit the break through MarkdownTextSpan so it composes on iOS; web and
|
||||
// Android keep the same "\n" they rendered before.
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
|
||||
@@ -94,8 +94,6 @@ import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
@@ -1634,12 +1632,6 @@ function ProjectBlock({
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const {
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(project.workspaces);
|
||||
const rowModel = useMemo(
|
||||
() =>
|
||||
buildSidebarProjectRowModel({
|
||||
@@ -1792,28 +1784,19 @@ function ProjectBlock({
|
||||
if (!collapsed) {
|
||||
if (project.workspaces.length > 0) {
|
||||
projectChildren = (
|
||||
<>
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={visibleWorkspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
onPress={toggleWorkspacesExpanded}
|
||||
testID={`sidebar-project-show-more-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={project.workspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
);
|
||||
} else if (rowModel.trailingAction.kind === "new_workspace") {
|
||||
projectChildren = (
|
||||
@@ -2090,12 +2073,6 @@ function ProjectModeList({
|
||||
const selectionEnabled = isWorkspaceRoute;
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
||||
const {
|
||||
visibleItems: visiblePinnedChats,
|
||||
expanded: pinnedChatsExpanded,
|
||||
canToggle: canTogglePinnedChats,
|
||||
toggleExpanded: togglePinnedChatsExpanded,
|
||||
} = useLimitedSidebarGroup(pinnedChats);
|
||||
const projectIconTargets = useMemo(
|
||||
() =>
|
||||
projects.flatMap((project) => {
|
||||
@@ -2326,6 +2303,7 @@ function ProjectModeList({
|
||||
canCopyBranchName={workspace.projectKind === "git"}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={false}
|
||||
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
@@ -2353,18 +2331,7 @@ function ProjectModeList({
|
||||
{pinnedChats.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
{visiblePinnedChats.map(renderPinnedChat)}
|
||||
{canTogglePinnedChats ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={pinnedChatsExpanded}
|
||||
onPress={togglePinnedChatsExpanded}
|
||||
testID="sidebar-pinned-show-more"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{pinnedCollapsed ? null : pinnedChats.map(renderPinnedChat)}
|
||||
</View>
|
||||
) : null}
|
||||
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
|
||||
@@ -2432,9 +2399,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
|
||||
// Schedules icon across the divider; their layout boxes have different insets.
|
||||
paddingTop: 2,
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
projectListContainer: {
|
||||
|
||||
@@ -47,7 +47,6 @@ export function PinnedSectionHeader({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
minHeight: 36,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronUp = withUnistyles(ChevronUp);
|
||||
|
||||
export function SidebarGroupToggleRow({
|
||||
expanded,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = t(
|
||||
expanded ? "sidebar.workspace.actions.showLess" : "sidebar.workspace.actions.showMore",
|
||||
);
|
||||
const rowStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
(Boolean(hovered) || pressed) && styles.rowHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole={isWeb ? undefined : "button"}
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
style={rowStyle}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<>
|
||||
<View style={styles.iconSlot}>
|
||||
{expanded ? (
|
||||
<ThemedChevronUp
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<ThemedChevronDown
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
minHeight: 32,
|
||||
marginLeft: theme.spacing[6],
|
||||
marginRight: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
userSelect: "none",
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
iconSlot: {
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
textHovered: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
@@ -42,8 +42,6 @@ import {
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
|
||||
|
||||
// Themed icon wrappers
|
||||
@@ -95,12 +93,6 @@ export function SidebarStatusWorkspaceList({
|
||||
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
const {
|
||||
visibleItems: visiblePinnedWorkspaces,
|
||||
expanded: pinnedWorkspacesExpanded,
|
||||
canToggle: canTogglePinnedWorkspaces,
|
||||
toggleExpanded: togglePinnedWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(pinnedWorkspaces);
|
||||
|
||||
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
||||
const content = (
|
||||
@@ -108,9 +100,9 @@ export function SidebarStatusWorkspaceList({
|
||||
{pinnedWorkspaces.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
{visiblePinnedWorkspaces.map((workspace) => (
|
||||
{pinnedCollapsed
|
||||
? null
|
||||
: pinnedWorkspaces.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
@@ -124,18 +116,10 @@ export function SidebarStatusWorkspaceList({
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={false}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
{canTogglePinnedWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={pinnedWorkspacesExpanded}
|
||||
onPress={togglePinnedWorkspacesExpanded}
|
||||
testID="sidebar-pinned-show-more"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{listHeaderComponent}
|
||||
@@ -205,89 +189,35 @@ function StatusGroupList({
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<StatusGroupRows
|
||||
key={group.bucket}
|
||||
group={group}
|
||||
collapsed={collapsedStatusGroupKeys.has(group.bucket)}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndex={shortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusGroupRows({
|
||||
group,
|
||||
collapsed,
|
||||
projectNamesByKey,
|
||||
shortcutIndex,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
}: {
|
||||
group: StatusGroup;
|
||||
collapsed: boolean;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
shortcutIndex: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const {
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(group.rows);
|
||||
|
||||
return (
|
||||
<View style={styles.statusGroupBlock}>
|
||||
<StatusGroupHeader group={group} collapsed={collapsed} />
|
||||
{!collapsed ? (
|
||||
<View
|
||||
style={styles.statusWorkspaceListContainer}
|
||||
testID={`sidebar-status-group-rows-${group.bucket}`}
|
||||
>
|
||||
{visibleWorkspaces.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
subtitle={buildStatusRowSubtitle({
|
||||
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
|
||||
hostLabel: showHostLabels
|
||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
||||
: null,
|
||||
})}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
onPress={toggleWorkspacesExpanded}
|
||||
testID={`sidebar-status-show-more-${group.bucket}`}
|
||||
/>
|
||||
<View key={group.bucket} style={styles.statusGroupBlock}>
|
||||
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
|
||||
{!collapsedStatusGroupKeys.has(group.bucket) ? (
|
||||
<View
|
||||
style={styles.statusWorkspaceListContainer}
|
||||
testID={`sidebar-status-group-rows-${group.bucket}`}
|
||||
>
|
||||
{group.rows.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
subtitle={buildStatusRowSubtitle({
|
||||
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
|
||||
hostLabel: showHostLabels
|
||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
||||
: null,
|
||||
})}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -793,8 +723,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
|
||||
paddingTop: 2,
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
pinnedSection: {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
const INITIAL_VISIBLE_ITEMS = 20;
|
||||
|
||||
export function useLimitedSidebarGroup<T>(items: readonly T[]) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibleItems = useMemo(
|
||||
() => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)),
|
||||
[expanded, items],
|
||||
);
|
||||
const canToggle = items.length > INITIAL_VISIBLE_ITEMS;
|
||||
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
||||
|
||||
return { visibleItems, expanded, canToggle, toggleExpanded };
|
||||
}
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
getWorkspacePaneDescriptors,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { useModifiedPanelTabIds } from "@/panels/panel-instance-attributes";
|
||||
import {
|
||||
WorkspacePaneContent,
|
||||
type WorkspacePaneContentModel,
|
||||
@@ -935,17 +934,11 @@ function SplitPaneView({
|
||||
);
|
||||
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
|
||||
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
|
||||
const modifiedPaneTabIds = useModifiedPanelTabIds({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabIds: paneTabIds,
|
||||
});
|
||||
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
|
||||
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
|
||||
const { mountedTabIds } = useMountedTabSet({
|
||||
activeTabId: activeTabDescriptor?.tabId ?? null,
|
||||
allTabIds: paneTabIds,
|
||||
retainedTabIds: modifiedPaneTabIds,
|
||||
cap: 3,
|
||||
});
|
||||
const mountedPaneTabIds = useMemo(
|
||||
|
||||
@@ -10,7 +10,6 @@ const theme = {
|
||||
md: 6,
|
||||
lg: 8,
|
||||
xl: 12,
|
||||
full: 9999,
|
||||
},
|
||||
borderWidth: {
|
||||
1: 1,
|
||||
@@ -103,41 +102,12 @@ describe("control geometry", () => {
|
||||
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
||||
});
|
||||
|
||||
it("keeps segmented controls ghost with fully rounded segments in a button-sized track", () => {
|
||||
it("subtracts segmented control inset from the nested segment radius", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
expect(geometry.segmentedContainerXs.padding).toBe(0);
|
||||
expect(geometry.segmentedContainerSm.padding).toBe(0);
|
||||
expect(geometry.segmentedContainerMd.padding).toBe(0);
|
||||
expect(geometry.segmentedSegmentXs.borderRadius).toBe(9999);
|
||||
expect(geometry.segmentedSegmentSm.borderRadius).toBe(9999);
|
||||
expect(geometry.segmentedSegmentMd.borderRadius).toBe(9999);
|
||||
expect(geometry.segmentedContainerXs.minHeight).toBe(geometry.buttonXs.minHeight);
|
||||
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
|
||||
expect(geometry.segmentedContainerMd.minHeight).toBe(geometry.buttonMd.minHeight);
|
||||
expect(geometry.segmentedSegmentXs.minHeight).toBe(24);
|
||||
expect(geometry.segmentedSegmentSm.minHeight).toBe(28);
|
||||
expect(geometry.segmentedSegmentMd.minHeight).toBe(38);
|
||||
});
|
||||
|
||||
it("keeps one size contract across buttons and segmented controls", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
// xs is a genuinely smaller tier, not sm with a different font.
|
||||
expect(geometry.buttonXs.minHeight).toBe(28);
|
||||
expect(geometry.buttonSm.minHeight).toBe(32);
|
||||
expect(geometry.buttonMd.minHeight).toBe(44);
|
||||
|
||||
// Same size name means the same label size on every control kind.
|
||||
expect(geometry.segmentedLabelXs.fontSize).toBe(12);
|
||||
expect(geometry.segmentedLabelXs.fontSize).toBe(geometry.buttonTextXs.fontSize);
|
||||
expect(geometry.segmentedLabelSm.fontSize).toBe(14);
|
||||
expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonText.fontSize);
|
||||
expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonText.fontSize);
|
||||
|
||||
// Same size name means the same horizontal padding on every control kind.
|
||||
expect(geometry.segmentedSegmentXs.paddingHorizontal).toBe(geometry.buttonXs.paddingHorizontal);
|
||||
expect(geometry.segmentedSegmentSm.paddingHorizontal).toBe(geometry.buttonSm.paddingHorizontal);
|
||||
expect(geometry.segmentedSegmentMd.paddingHorizontal).toBe(geometry.buttonMd.paddingHorizontal);
|
||||
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
|
||||
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
|
||||
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
|
||||
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
|
||||
export type FieldControlSize = "sm" | "md";
|
||||
export type SegmentedControlSize = "xs" | "sm" | "md";
|
||||
export type SegmentedControlSize = "sm" | "md";
|
||||
export type ControlInteractionPhase = "rest" | "hover" | "active";
|
||||
|
||||
export interface ControlInteractionState {
|
||||
@@ -22,10 +22,8 @@ export interface ControlInteractionStyleMap {
|
||||
controlDisabled?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const TIGHT_CONTROL_HEIGHT = 28;
|
||||
const COMPACT_CONTROL_HEIGHT = 32;
|
||||
const FIELD_CONTROL_HEIGHT = 44;
|
||||
const SEGMENTED_TIGHT_INSET = 2;
|
||||
const SEGMENTED_COMPACT_INSET = 2;
|
||||
const SEGMENTED_FIELD_INSET = 3;
|
||||
const SWITCH_TRACK_WIDTH = 34;
|
||||
@@ -37,7 +35,6 @@ const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
|
||||
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
|
||||
|
||||
const controlHeights = {
|
||||
tight: TIGHT_CONTROL_HEIGHT,
|
||||
compact: COMPACT_CONTROL_HEIGHT,
|
||||
field: FIELD_CONTROL_HEIGHT,
|
||||
};
|
||||
@@ -50,7 +47,6 @@ export const buttonIconSize: Record<ButtonControlSize, number> = {
|
||||
};
|
||||
|
||||
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
|
||||
xs: ICON_SIZE.xs,
|
||||
sm: ICON_SIZE.sm,
|
||||
md: ICON_SIZE.md,
|
||||
};
|
||||
@@ -62,6 +58,10 @@ export const switchGeometry = {
|
||||
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
|
||||
};
|
||||
|
||||
function nestedRadius(containerRadius: number, inset: number): number {
|
||||
return Math.max(0, containerRadius - inset);
|
||||
}
|
||||
|
||||
function fieldLineHeight(fontSize: number): number {
|
||||
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
||||
}
|
||||
@@ -121,6 +121,8 @@ export function createControlGeometry(theme: Theme) {
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: fieldTextMdLineHeight,
|
||||
};
|
||||
const segmentedContainerSmRadius = theme.borderRadius.md;
|
||||
const segmentedContainerMdRadius = theme.borderRadius.lg;
|
||||
const switchControl = {
|
||||
minHeight: controlHeights.compact,
|
||||
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
||||
@@ -128,7 +130,7 @@ export function createControlGeometry(theme: Theme) {
|
||||
|
||||
return {
|
||||
buttonXs: {
|
||||
minHeight: controlHeights.tight,
|
||||
minHeight: controlHeights.compact,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
@@ -192,41 +194,31 @@ export function createControlGeometry(theme: Theme) {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
switchControl,
|
||||
segmentedContainerXs: {
|
||||
minHeight: controlHeights.tight,
|
||||
padding: 0,
|
||||
},
|
||||
segmentedContainerSm: {
|
||||
minHeight: controlHeights.compact,
|
||||
padding: 0,
|
||||
padding: SEGMENTED_COMPACT_INSET,
|
||||
borderRadius: segmentedContainerSmRadius,
|
||||
},
|
||||
segmentedContainerMd: {
|
||||
minHeight: controlHeights.field,
|
||||
padding: 0,
|
||||
},
|
||||
segmentedSegmentXs: {
|
||||
minHeight: controlHeights.tight - SEGMENTED_TIGHT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
padding: SEGMENTED_FIELD_INSET,
|
||||
borderRadius: segmentedContainerMdRadius,
|
||||
},
|
||||
segmentedSegmentSm: {
|
||||
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
|
||||
},
|
||||
segmentedSegmentMd: {
|
||||
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
segmentedLabelXs: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
|
||||
},
|
||||
segmentedLabelSm: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
segmentedLabelMd: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -34,7 +33,6 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||
|
||||
// Action status for menu items with loading/success feedback
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
@@ -218,16 +216,12 @@ function renderDropdownSurface(input: {
|
||||
style={surfaceStyle}
|
||||
frameStyle={frameStyle}
|
||||
entering={contentEntering}
|
||||
exiting={
|
||||
isWeb
|
||||
? undefined
|
||||
: contentExiting.withCallback((finished) => {
|
||||
"worklet";
|
||||
if (finished) {
|
||||
runOnJS(onExited)();
|
||||
}
|
||||
})
|
||||
}
|
||||
exiting={contentExiting.withCallback((finished) => {
|
||||
"worklet";
|
||||
if (finished) {
|
||||
runOnJS(onExited)();
|
||||
}
|
||||
})}
|
||||
>
|
||||
{body}
|
||||
</FloatingSurface>
|
||||
@@ -502,17 +496,6 @@ export function DropdownMenuContent({
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !modalVisible || typeof window === "undefined") return undefined;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.stopPropagation();
|
||||
handleClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [handleClose, modalVisible]);
|
||||
|
||||
// Measure trigger when opening
|
||||
useEffect(() => {
|
||||
if (!open || !triggerRef.current) {
|
||||
@@ -630,34 +613,6 @@ export function DropdownMenuContent({
|
||||
</View>
|
||||
);
|
||||
|
||||
const overlay = (
|
||||
<View style={[styles.overlay, isWeb ? styles.overlayWeb : null]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
{!closing
|
||||
? renderDropdownSurface({
|
||||
frameStyle,
|
||||
testID,
|
||||
surfaceStyle,
|
||||
scrollable,
|
||||
scrollViewportStyle,
|
||||
content,
|
||||
surfaceNativeID,
|
||||
onExited: () => setModalVisible(false),
|
||||
})
|
||||
: null}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
return createPortal(overlay, getOverlayRoot());
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
@@ -667,7 +622,27 @@ export function DropdownMenuContent({
|
||||
onDismiss={flushPendingSelect}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
{overlay}
|
||||
<View style={styles.overlay}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
{!closing
|
||||
? renderDropdownSurface({
|
||||
frameStyle,
|
||||
testID,
|
||||
surfaceStyle,
|
||||
scrollable,
|
||||
scrollViewportStyle,
|
||||
content,
|
||||
surfaceNativeID,
|
||||
onExited: () => setModalVisible(false),
|
||||
})
|
||||
: null}
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -903,11 +878,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
},
|
||||
overlayWeb: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: OVERLAY_Z.modal,
|
||||
pointerEvents: "auto" as const,
|
||||
},
|
||||
backdrop: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
|
||||
@@ -41,7 +41,7 @@ function SegmentIcon({ icon, iconSize, iconColor }: SegmentIconProps) {
|
||||
|
||||
const ThemedSegmentIcon = withUnistyles(SegmentIcon);
|
||||
|
||||
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.surface0 });
|
||||
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
|
||||
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
@@ -53,14 +53,9 @@ export function SegmentedControl<T extends string>({
|
||||
style,
|
||||
testID,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const sizeStyles = {
|
||||
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
|
||||
sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm },
|
||||
md: { container: styles.containerMd, segment: styles.segmentMd, label: styles.labelMd },
|
||||
}[size];
|
||||
const containerSizeStyle = sizeStyles.container;
|
||||
const segmentSizeStyle = sizeStyles.segment;
|
||||
const labelSizeStyle = sizeStyles.label;
|
||||
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
|
||||
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
||||
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
||||
const iconSize = segmentedIconSize[size];
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -166,12 +161,9 @@ const styles = StyleSheet.create((theme) => {
|
||||
return {
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
containerXs: {
|
||||
...geometry.segmentedContainerXs,
|
||||
alignItems: "stretch",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
gap: 2,
|
||||
},
|
||||
containerSm: {
|
||||
...geometry.segmentedContainerSm,
|
||||
@@ -186,9 +178,6 @@ const styles = StyleSheet.create((theme) => {
|
||||
flexShrink: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
segmentXs: {
|
||||
...geometry.segmentedSegmentXs,
|
||||
},
|
||||
segmentSm: {
|
||||
...geometry.segmentedSegmentSm,
|
||||
},
|
||||
@@ -196,13 +185,18 @@ const styles = StyleSheet.create((theme) => {
|
||||
...geometry.segmentedSegmentMd,
|
||||
},
|
||||
segmentSelected: {
|
||||
backgroundColor: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
segmentHover: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
segmentPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
segmentDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
@@ -215,9 +209,6 @@ const styles = StyleSheet.create((theme) => {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
labelXs: {
|
||||
...geometry.segmentedLabelXs,
|
||||
},
|
||||
labelSm: {
|
||||
...geometry.segmentedLabelSm,
|
||||
},
|
||||
@@ -225,7 +216,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
...geometry.segmentedLabelMd,
|
||||
},
|
||||
labelSelected: {
|
||||
color: theme.colors.surface0,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
@@ -24,12 +23,13 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
type Align = "start" | "center" | "end";
|
||||
@@ -446,6 +446,7 @@ export function TooltipContent({
|
||||
maxWidth?: number;
|
||||
}>): ReactElement | null {
|
||||
const ctx = useTooltipContext("TooltipContent");
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
@@ -515,22 +516,23 @@ export function TooltipContent({
|
||||
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
|
||||
// exact same positioning math as DropdownMenu, without hover feedback loops.
|
||||
if (isWeb) {
|
||||
return createPortal(
|
||||
<View pointerEvents="none" style={styles.portalOverlay}>
|
||||
<FloatingSurface
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={contentStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</FloatingSurface>
|
||||
</View>,
|
||||
getOverlayRoot(),
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="none" style={styles.portalOverlay}>
|
||||
<FloatingSurface
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={contentStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -568,7 +570,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: OVERLAY_Z.tooltip,
|
||||
zIndex: 1000,
|
||||
},
|
||||
content: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { Keyboard, ScrollView, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -665,11 +665,7 @@ export function WorkspaceDraftAgentTab({
|
||||
});
|
||||
|
||||
const inputAreaWrapperStyle = useMemo(
|
||||
() => [
|
||||
animatedStaticStyles.inputAreaWrapper,
|
||||
{ paddingBottom: insets.bottom },
|
||||
composerKeyboardStyle,
|
||||
],
|
||||
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }, composerKeyboardStyle],
|
||||
[insets.bottom, composerKeyboardStyle],
|
||||
);
|
||||
|
||||
@@ -776,12 +772,6 @@ export function WorkspaceDraftAgentTab({
|
||||
);
|
||||
}
|
||||
|
||||
const animatedStaticStyles = RNStyleSheet.create({
|
||||
inputAreaWrapper: {
|
||||
width: "100%",
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -805,6 +795,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
configSection: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
inputAreaWrapper: {
|
||||
width: "100%",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
importPillRow: {
|
||||
width: "100%",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
|
||||
@@ -48,9 +48,7 @@ interface SearchCall {
|
||||
|
||||
interface HarnessInput {
|
||||
initialAttachments?: UserComposerAttachment[];
|
||||
initialCwd?: string;
|
||||
initialText?: string;
|
||||
onPullRequestDetected?: () => void;
|
||||
remote?: string | null;
|
||||
}
|
||||
|
||||
@@ -74,14 +72,6 @@ function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { cal
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -97,8 +87,6 @@ function createWrapper() {
|
||||
|
||||
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
||||
const [text, setText] = useState(input.initialText ?? "");
|
||||
const [searchClient, setSearchClient] = useState(client);
|
||||
const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd);
|
||||
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
|
||||
input.initialAttachments ?? [],
|
||||
);
|
||||
@@ -106,22 +94,18 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
||||
text,
|
||||
remoteUrl: input.remote ?? remoteUrl,
|
||||
attachments,
|
||||
client: searchClient,
|
||||
client,
|
||||
isConnected: true,
|
||||
serverId: "server-1",
|
||||
cwd: workingDirectory,
|
||||
cwd,
|
||||
setAttachments,
|
||||
onPullRequestDetected: input.onPullRequestDetected,
|
||||
});
|
||||
|
||||
return {
|
||||
text,
|
||||
setText,
|
||||
setSearchClient,
|
||||
setWorkingDirectory,
|
||||
attachments,
|
||||
setAttachments,
|
||||
isResolving: autoAttach.isResolving,
|
||||
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
|
||||
};
|
||||
}
|
||||
@@ -137,20 +121,14 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSearchClient([pr101]);
|
||||
const onPullRequestDetected = vi.fn();
|
||||
const { result } = renderHook(() => useHarness(client, { onPullRequestDetected }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
expect(result.current.isResolving).toBe(true);
|
||||
expect(onPullRequestDetected).toHaveBeenCalledTimes(1);
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
||||
expect(result.current.isResolving).toBe(false);
|
||||
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -230,115 +208,4 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stays resolving while overlapping lookups share a ref", async () => {
|
||||
vi.useFakeTimers();
|
||||
const firstLookup = deferred<ForgeSearchPayload>();
|
||||
const secondLookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
searchForge: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstLookup.promise)
|
||||
.mockReturnValueOnce(secondLookup.promise),
|
||||
};
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText(
|
||||
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/pull/202",
|
||||
);
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Still https://github.com/acme/paseo/pull/202");
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
await act(async () => {
|
||||
firstLookup.resolve(githubPayload([], "search-101"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.isResolving).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
secondLookup.resolve(githubPayload([], "search-202"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.isResolving).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stops resolving when an in-flight PR URL is removed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const lookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||
};
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Review https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
act(() => {
|
||||
result.current.setText("");
|
||||
});
|
||||
|
||||
expect(result.current.isResolving).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("ignores a lookup that finishes after the target changes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const lookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||
};
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Review https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkingDirectory("/other-repo");
|
||||
});
|
||||
await flushDebounce();
|
||||
await act(async () => {
|
||||
lookup.resolve(githubPayload([pr101], "search-101"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.attachments).toEqual([]);
|
||||
expect(client.searchForge).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("accepts a lookup after the transport client is replaced for the same target", async () => {
|
||||
vi.useFakeTimers();
|
||||
const lookup = deferred<ForgeSearchPayload>();
|
||||
const firstClient: ForgeSearchClient = {
|
||||
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||
};
|
||||
const replacementClient = createSearchClient([pr101]);
|
||||
const { result } = renderHook(() => useHarness(firstClient), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Review https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
act(() => {
|
||||
result.current.setSearchClient(replacementClient);
|
||||
});
|
||||
await act(async () => {
|
||||
lookup.resolve(githubPayload([pr101], "search-101"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
||||
expect(replacementClient.calls).toEqual([]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
@@ -27,12 +26,9 @@ interface ComposerGithubAutoAttachInput {
|
||||
cwd: string;
|
||||
supportsForgeSearch?: boolean;
|
||||
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
||||
onPullRequestDetected?: () => void;
|
||||
onPullRequestAdded?: (item: ForgeSearchItem) => void;
|
||||
}
|
||||
|
||||
interface ComposerGithubAutoAttachResult {
|
||||
isResolving: boolean;
|
||||
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
|
||||
}
|
||||
|
||||
@@ -43,24 +39,10 @@ export function useComposerGithubAutoAttach(
|
||||
const latestRef = useRef(params);
|
||||
const removedRefKeysRef = useRef(new Set<string>());
|
||||
const pendingRefKeysRef = useRef(new Set<string>());
|
||||
const presentPullRequestKeysRef = useRef(new Set<string>());
|
||||
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd });
|
||||
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
latestRef.current = params;
|
||||
|
||||
useEffect(() => {
|
||||
suppressRefsCarriedAcrossTargets({
|
||||
params: latestRef.current,
|
||||
previousTargetRef,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
});
|
||||
notifyNewPullRequestRefs({
|
||||
params: latestRef.current,
|
||||
presentPullRequestKeysRef,
|
||||
});
|
||||
const refs = refsReadyForLookup({
|
||||
params: latestRef.current,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
@@ -70,15 +52,6 @@ export function useComposerGithubAutoAttach(
|
||||
return;
|
||||
}
|
||||
|
||||
const refKeys = refs.map(githubRefKey);
|
||||
setResolvingRefCounts((current) => addKeys(current, refKeys));
|
||||
let resolvingReleased = false;
|
||||
const releaseResolving = () => {
|
||||
if (resolvingReleased) return;
|
||||
resolvingReleased = true;
|
||||
clearResolvingKeys(setResolvingRefCounts, refKeys);
|
||||
};
|
||||
|
||||
const timerId = setTimeout(() => {
|
||||
void attachRefs({
|
||||
refs,
|
||||
@@ -86,12 +59,11 @@ export function useComposerGithubAutoAttach(
|
||||
latestRef,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
pendingRefKeys: pendingRefKeysRef.current,
|
||||
}).finally(releaseResolving);
|
||||
});
|
||||
}, AUTO_ATTACH_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timerId);
|
||||
releaseResolving();
|
||||
};
|
||||
}, [
|
||||
params.text,
|
||||
@@ -114,84 +86,12 @@ export function useComposerGithubAutoAttach(
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
isResolving: resolvingRefCounts.size > 0,
|
||||
markGithubAttachmentRemoved,
|
||||
}),
|
||||
[markGithubAttachmentRemoved, resolvingRefCounts.size],
|
||||
[markGithubAttachmentRemoved],
|
||||
);
|
||||
}
|
||||
|
||||
function suppressRefsCarriedAcrossTargets({
|
||||
params,
|
||||
previousTargetRef,
|
||||
removedRefKeys,
|
||||
}: {
|
||||
params: ComposerGithubAutoAttachInput;
|
||||
previousTargetRef: RefObject<{ serverId: string; cwd: string }>;
|
||||
removedRefKeys: Set<string>;
|
||||
}): void {
|
||||
const previous = previousTargetRef.current;
|
||||
const targetChanged =
|
||||
previous.cwd.trim().length > 0 &&
|
||||
params.cwd.trim().length > 0 &&
|
||||
(previous.serverId !== params.serverId || previous.cwd !== params.cwd);
|
||||
previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd };
|
||||
if (!targetChanged) return;
|
||||
|
||||
for (const ref of extractGithubRefs(params.text, params.remoteUrl)) {
|
||||
removedRefKeys.add(githubRefKey(ref));
|
||||
}
|
||||
}
|
||||
|
||||
function notifyNewPullRequestRefs({
|
||||
params,
|
||||
presentPullRequestKeysRef,
|
||||
}: {
|
||||
params: ComposerGithubAutoAttachInput;
|
||||
presentPullRequestKeysRef: RefObject<Set<string>>;
|
||||
}): void {
|
||||
const currentKeys = new Set(
|
||||
extractGithubRefs(params.text, params.remoteUrl)
|
||||
.filter((ref) => ref.kind === "pull")
|
||||
.map(githubRefKey),
|
||||
);
|
||||
for (const key of currentKeys) {
|
||||
if (!presentPullRequestKeysRef.current.has(key)) {
|
||||
params.onPullRequestDetected?.();
|
||||
}
|
||||
}
|
||||
presentPullRequestKeysRef.current = currentKeys;
|
||||
}
|
||||
|
||||
function addKeys(
|
||||
current: ReadonlyMap<string, number>,
|
||||
keys: readonly string[],
|
||||
): ReadonlyMap<string, number> {
|
||||
const nextCounts = new Map(current);
|
||||
for (const key of keys) nextCounts.set(key, (nextCounts.get(key) ?? 0) + 1);
|
||||
return nextCounts;
|
||||
}
|
||||
|
||||
function removeKeys(
|
||||
current: ReadonlyMap<string, number>,
|
||||
keys: readonly string[],
|
||||
): ReadonlyMap<string, number> {
|
||||
const next = new Map(current);
|
||||
for (const key of keys) {
|
||||
const count = next.get(key) ?? 0;
|
||||
if (count <= 1) next.delete(key);
|
||||
else next.set(key, count - 1);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function clearResolvingKeys(
|
||||
setResolvingRefCounts: Dispatch<SetStateAction<ReadonlyMap<string, number>>>,
|
||||
keys: readonly string[],
|
||||
): void {
|
||||
setResolvingRefCounts((current) => removeKeys(current, keys));
|
||||
}
|
||||
|
||||
async function attachRefs({
|
||||
refs,
|
||||
queryClient,
|
||||
@@ -242,28 +142,16 @@ async function attachRef({
|
||||
return;
|
||||
}
|
||||
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
|
||||
const current = latestRef.current;
|
||||
if (
|
||||
!item ||
|
||||
removedRefKeys.has(key) ||
|
||||
!isSameLookupTarget(snapshot, current) ||
|
||||
!isRefStillPresent(ref, current)
|
||||
) {
|
||||
if (!item || removedRefKeys.has(key) || !isRefStillPresent(ref, latestRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAttachmentSelectedForGithubItem(current.attachments, item)) {
|
||||
return;
|
||||
}
|
||||
current.setAttachments((attachments) => {
|
||||
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(attachments, item)) {
|
||||
return attachments;
|
||||
latestRef.current.setAttachments((current) => {
|
||||
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) {
|
||||
return current;
|
||||
}
|
||||
return toggleGithubAttachment(attachments, item);
|
||||
return toggleGithubAttachment(current, item);
|
||||
});
|
||||
if (item.kind === "change_request") {
|
||||
current.onPullRequestAdded?.(item);
|
||||
}
|
||||
}
|
||||
|
||||
function refsReadyForLookup({
|
||||
@@ -324,17 +212,6 @@ function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput
|
||||
);
|
||||
}
|
||||
|
||||
function isSameLookupTarget(
|
||||
initial: ComposerGithubAutoAttachInput,
|
||||
current: ComposerGithubAutoAttachInput,
|
||||
): boolean {
|
||||
return (
|
||||
initial.serverId === current.serverId &&
|
||||
initial.cwd === current.cwd &&
|
||||
initial.remoteUrl === current.remoteUrl
|
||||
);
|
||||
}
|
||||
|
||||
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
|
||||
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Pressable,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
StyleSheet as RNStyleSheet,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
@@ -769,8 +768,6 @@ interface ComposerProps {
|
||||
submitIcon?: "arrow" | "return";
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
/** When true, waits for pasted GitHub links to resolve before enabling submit. */
|
||||
waitForGithubAutoAttachOnSubmit?: boolean;
|
||||
submitBehavior?: "clear" | "preserve-and-lock";
|
||||
/** When true, blurs the input immediately when submitting. */
|
||||
blurOnSubmit?: boolean;
|
||||
@@ -780,8 +777,6 @@ interface ComposerProps {
|
||||
attachmentScopeKeys?: readonly string[];
|
||||
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
||||
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
||||
onGithubPrDetected?: () => void;
|
||||
onGithubPrAutoAttach?: (item: ForgeSearchItem) => void;
|
||||
cwd: string;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
/** When true, auto-focuses the text input on web. */
|
||||
@@ -986,7 +981,6 @@ export function Composer({
|
||||
submitButtonTestID,
|
||||
submitIcon = "arrow",
|
||||
isSubmitLoading = false,
|
||||
waitForGithubAutoAttachOnSubmit = false,
|
||||
submitBehavior = "clear",
|
||||
blurOnSubmit = false,
|
||||
value,
|
||||
@@ -995,8 +989,6 @@ export function Composer({
|
||||
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
||||
onOpenWorkspaceAttachment,
|
||||
onChangeAttachments,
|
||||
onGithubPrDetected,
|
||||
onGithubPrAutoAttach,
|
||||
cwd,
|
||||
clearDraft,
|
||||
autoFocus = false,
|
||||
@@ -1078,8 +1070,6 @@ export function Composer({
|
||||
cwd,
|
||||
supportsForgeSearch,
|
||||
setAttachments: setSelectedAttachments,
|
||||
onPullRequestDetected: onGithubPrDetected,
|
||||
onPullRequestAdded: onGithubPrAutoAttach,
|
||||
});
|
||||
const [cursorIndex, setCursorIndex] = useState(0);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -1917,7 +1907,7 @@ export function Composer({
|
||||
);
|
||||
|
||||
const composerContainerStyle = useMemo(
|
||||
() => [animatedStaticStyles.container, keyboardAnimatedStyle],
|
||||
() => [styles.container, keyboardAnimatedStyle],
|
||||
[keyboardAnimatedStyle],
|
||||
);
|
||||
const inputAreaContainerStyle = useMemo(
|
||||
@@ -1959,11 +1949,7 @@ export function Composer({
|
||||
|
||||
const messageInputContainerRef = useRef<View>(null);
|
||||
|
||||
const isSubmitBusy =
|
||||
isProcessing ||
|
||||
isSubmitLoading ||
|
||||
isUploadingFile ||
|
||||
(waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
|
||||
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
|
||||
|
||||
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
|
||||
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
|
||||
@@ -2078,14 +2064,11 @@ export function Composer({
|
||||
);
|
||||
}
|
||||
|
||||
const animatedStaticStyles = RNStyleSheet.create({
|
||||
const styles = StyleSheet.create((theme: Theme) => ({
|
||||
container: {
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme: Theme) => ({
|
||||
borderSeparator: {
|
||||
height: theme.borderWidth[1],
|
||||
backgroundColor: theme.colors.border,
|
||||
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
resolveComposerSurfacePresentation,
|
||||
runAlternateSendAction,
|
||||
runDefaultSendAction,
|
||||
runMessageInputKeyboardAction,
|
||||
stopRealtimeVoice,
|
||||
} from "./state";
|
||||
|
||||
@@ -497,6 +496,65 @@ function handleDesktopKeyPressImpl(
|
||||
ctx.handleDefaultSendAction();
|
||||
}
|
||||
|
||||
interface KeyboardActionHandlers {
|
||||
textInputRef: React.MutableRefObject<
|
||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||
>;
|
||||
isDictatingRef: React.MutableRefObject<boolean>;
|
||||
sendAfterTranscriptRef: React.MutableRefObject<boolean>;
|
||||
confirmDictation: () => void | Promise<void>;
|
||||
cancelDictation: () => void | Promise<void>;
|
||||
startDictationIfAvailable: () => Promise<void>;
|
||||
handleToggleRealtimeVoiceShortcut: () => void;
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
voice: { toggleMute: () => void } | null | undefined;
|
||||
}
|
||||
|
||||
function runKeyboardActionImpl(
|
||||
action: MessageInputKeyboardActionKind,
|
||||
h: KeyboardActionHandlers,
|
||||
): boolean {
|
||||
if (action === "focus") {
|
||||
h.textInputRef.current?.focus();
|
||||
return true;
|
||||
}
|
||||
if (action === "send" || action === "dictation-confirm") {
|
||||
if (h.isDictatingRef.current) {
|
||||
h.sendAfterTranscriptRef.current = true;
|
||||
void h.confirmDictation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (action === "voice-toggle") {
|
||||
h.handleToggleRealtimeVoiceShortcut();
|
||||
return true;
|
||||
}
|
||||
if (action === "voice-mute-toggle") {
|
||||
if (h.isRealtimeVoiceForCurrentAgent) {
|
||||
h.voice?.toggleMute();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (action === "dictation-cancel") {
|
||||
if (h.isDictatingRef.current) {
|
||||
void h.cancelDictation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (action === "dictation-toggle") {
|
||||
if (h.isDictatingRef.current) {
|
||||
h.sendAfterTranscriptRef.current = true;
|
||||
void h.confirmDictation();
|
||||
} else {
|
||||
void h.startDictationIfAvailable();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getTextInputNativeElement(
|
||||
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
|
||||
): HTMLElement | null {
|
||||
@@ -858,18 +916,22 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
||||
interface StartDictationContext {
|
||||
dictationUnavailableMessage: string | null | undefined;
|
||||
canStartDictation: () => boolean;
|
||||
isDictatingRef: React.MutableRefObject<boolean>;
|
||||
toast: { error: (msg: string) => void };
|
||||
startDictation: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
|
||||
if (ctx.dictationUnavailableMessage) {
|
||||
ctx.isDictatingRef.current = false;
|
||||
ctx.toast.error(ctx.dictationUnavailableMessage);
|
||||
return;
|
||||
}
|
||||
if (!ctx.canStartDictation()) {
|
||||
ctx.isDictatingRef.current = false;
|
||||
return;
|
||||
}
|
||||
ctx.isDictatingRef.current = true;
|
||||
await ctx.startDictation();
|
||||
}
|
||||
|
||||
@@ -1214,18 +1276,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
textInputRef.current?.blur?.();
|
||||
},
|
||||
runKeyboardAction: (action) =>
|
||||
runMessageInputKeyboardAction(action, {
|
||||
focusInput: () => textInputRef.current?.focus(),
|
||||
isDictationRecording: isDictationActive,
|
||||
markTranscriptForSend: () => {
|
||||
sendAfterTranscriptRef.current = true;
|
||||
},
|
||||
runKeyboardActionImpl(action, {
|
||||
textInputRef,
|
||||
isDictatingRef,
|
||||
sendAfterTranscriptRef,
|
||||
confirmDictation,
|
||||
cancelDictation,
|
||||
startDictation: startDictationIfAvailable,
|
||||
toggleRealtimeVoice: handleToggleRealtimeVoiceShortcut,
|
||||
isRealtimeVoiceActive: isRealtimeVoiceForCurrentAgent,
|
||||
toggleRealtimeVoiceMute: () => voice?.toggleMute(),
|
||||
startDictationIfAvailable,
|
||||
handleToggleRealtimeVoiceShortcut,
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
voice,
|
||||
}),
|
||||
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
|
||||
}));
|
||||
@@ -1309,7 +1369,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
const {
|
||||
isRecording: isDictating,
|
||||
isRecordingActive: isDictationActive,
|
||||
isProcessing: isDictationProcessing,
|
||||
partialTranscript: _dictationPartialTranscript,
|
||||
volume: dictationVolume,
|
||||
@@ -1330,6 +1389,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
enableDuration: true,
|
||||
});
|
||||
|
||||
const isDictatingRef = useRef(isDictating);
|
||||
useEffect(() => {
|
||||
isDictatingRef.current = isDictating;
|
||||
}, [isDictating]);
|
||||
|
||||
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
|
||||
voice,
|
||||
voiceServerId,
|
||||
@@ -1356,6 +1420,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
startDictationIfAvailableImpl({
|
||||
dictationUnavailableMessage,
|
||||
canStartDictation,
|
||||
isDictatingRef,
|
||||
toast,
|
||||
startDictation,
|
||||
}),
|
||||
|
||||
@@ -4,40 +4,12 @@ import {
|
||||
resolveComposerSurfacePresentation,
|
||||
runAlternateSendAction,
|
||||
runDefaultSendAction,
|
||||
runMessageInputKeyboardAction,
|
||||
stopRealtimeVoice,
|
||||
} from "./state";
|
||||
|
||||
const connected = { isConnected: true } as never;
|
||||
const disconnected = { isConnected: false } as never;
|
||||
|
||||
function createDictationKeyboard({ startsRecording }: { startsRecording: boolean }) {
|
||||
let isRecording = false;
|
||||
const actions: string[] = [];
|
||||
|
||||
return {
|
||||
actions,
|
||||
pressDictationShortcut: () =>
|
||||
runMessageInputKeyboardAction("dictation-toggle", {
|
||||
focusInput: () => undefined,
|
||||
isDictationRecording: () => isRecording,
|
||||
markTranscriptForSend: () => actions.push("send transcript"),
|
||||
startDictation: () => {
|
||||
actions.push("start");
|
||||
isRecording = startsRecording;
|
||||
},
|
||||
confirmDictation: () => {
|
||||
actions.push("confirm");
|
||||
isRecording = false;
|
||||
},
|
||||
cancelDictation: () => undefined,
|
||||
toggleRealtimeVoice: () => undefined,
|
||||
isRealtimeVoiceActive: false,
|
||||
toggleRealtimeVoiceMute: () => undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("composer surface presentation", () => {
|
||||
it("shows only the input when no voice overlay is active", () => {
|
||||
expect(resolveComposerSurfacePresentation(false)).toEqual({
|
||||
@@ -142,27 +114,6 @@ describe("computeCanStartDictation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dictation keyboard behavior", () => {
|
||||
it("starts dictation again after the previous dictation finishes", () => {
|
||||
const keyboard = createDictationKeyboard({ startsRecording: true });
|
||||
|
||||
keyboard.pressDictationShortcut();
|
||||
keyboard.pressDictationShortcut();
|
||||
keyboard.pressDictationShortcut();
|
||||
|
||||
expect(keyboard.actions).toEqual(["start", "send transcript", "confirm", "start"]);
|
||||
});
|
||||
|
||||
it("can retry when starting dictation does not enter the recording state", () => {
|
||||
const keyboard = createDictationKeyboard({ startsRecording: false });
|
||||
|
||||
keyboard.pressDictationShortcut();
|
||||
keyboard.pressDictationShortcut();
|
||||
|
||||
expect(keyboard.actions).toEqual(["start", "start"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer send behavior", () => {
|
||||
function actions() {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { MessagePayload } from "@/composer/types";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
|
||||
@@ -46,18 +45,6 @@ interface SendActionContext {
|
||||
handleQueueMessage: () => void;
|
||||
}
|
||||
|
||||
interface MessageInputKeyboardActions {
|
||||
focusInput: () => void;
|
||||
isDictationRecording: () => boolean;
|
||||
markTranscriptForSend: () => void;
|
||||
confirmDictation: () => void | Promise<void>;
|
||||
cancelDictation: () => void | Promise<void>;
|
||||
startDictation: () => void | Promise<void>;
|
||||
toggleRealtimeVoice: () => void;
|
||||
isRealtimeVoiceActive: boolean;
|
||||
toggleRealtimeVoiceMute: () => void;
|
||||
}
|
||||
|
||||
export function computeCanStartDictation(input: {
|
||||
client: DaemonClient | null;
|
||||
isReadyForDictation: boolean | undefined;
|
||||
@@ -89,51 +76,6 @@ export function runAlternateSendAction(ctx: SendActionContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function runMessageInputKeyboardAction(
|
||||
action: MessageInputKeyboardActionKind,
|
||||
actions: MessageInputKeyboardActions,
|
||||
): boolean {
|
||||
if (action === "focus") {
|
||||
actions.focusInput();
|
||||
return true;
|
||||
}
|
||||
if (action === "send" || action === "dictation-confirm") {
|
||||
if (actions.isDictationRecording()) {
|
||||
actions.markTranscriptForSend();
|
||||
void actions.confirmDictation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (action === "voice-toggle") {
|
||||
actions.toggleRealtimeVoice();
|
||||
return true;
|
||||
}
|
||||
if (action === "voice-mute-toggle") {
|
||||
if (actions.isRealtimeVoiceActive) {
|
||||
actions.toggleRealtimeVoiceMute();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (action === "dictation-cancel") {
|
||||
if (actions.isDictationRecording()) {
|
||||
void actions.cancelDictation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (action === "dictation-toggle") {
|
||||
if (actions.isDictationRecording()) {
|
||||
actions.markTranscriptForSend();
|
||||
void actions.confirmDictation();
|
||||
} else {
|
||||
void actions.startDictation();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<void> {
|
||||
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
|
||||
|
||||
|
||||
@@ -387,6 +387,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const toast = useToast();
|
||||
|
||||
// Zustand store actions
|
||||
const initializeSession = useSessionStore((state) => state.initializeSession);
|
||||
const clearSession = useSessionStore((state) => state.clearSession);
|
||||
const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio);
|
||||
const setMessages = useSessionStore((state) => state.setMessages);
|
||||
const setCurrentAssistantMessage = useSessionStore((state) => state.setCurrentAssistantMessage);
|
||||
@@ -408,9 +410,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
|
||||
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
|
||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
|
||||
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
||||
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
|
||||
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||
const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer);
|
||||
|
||||
// Track focused agent for heartbeat
|
||||
const focusedAgentId = useSessionStore(
|
||||
@@ -520,6 +524,17 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
[serverId],
|
||||
);
|
||||
|
||||
// Initialize session in store
|
||||
useEffect(() => {
|
||||
const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0;
|
||||
initializeSession(serverId, client, generation);
|
||||
}, [serverId, client, initializeSession]);
|
||||
|
||||
useEffect(() => {
|
||||
const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0;
|
||||
updateSessionClient(serverId, client, generation);
|
||||
}, [serverId, client, updateSessionClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo) {
|
||||
@@ -1312,5 +1327,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
[client],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearWorkspaceSetupServer(serverId);
|
||||
clearSession(serverId);
|
||||
};
|
||||
}, [clearSession, clearWorkspaceSetupServer, serverId]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -37,10 +37,10 @@ const CATALOG_DATA = [
|
||||
title: "Auggie CLI",
|
||||
description:
|
||||
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||
version: "0.33.0",
|
||||
version: "0.32.0",
|
||||
iconId: "auggie",
|
||||
installLink: "https://www.augmentcode.com/",
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"],
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.32.0", "--acp"],
|
||||
env: {
|
||||
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
||||
},
|
||||
@@ -59,10 +59,10 @@ const CATALOG_DATA = [
|
||||
title: "Cline",
|
||||
description:
|
||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
version: "3.0.46",
|
||||
version: "3.0.44",
|
||||
iconId: "cline",
|
||||
installLink: "https://cline.bot/cli",
|
||||
command: ["npx", "-y", "cline@3.0.46", "--acp"],
|
||||
command: ["npx", "-y", "cline@3.0.44", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codebuddy-code",
|
||||
@@ -122,10 +122,10 @@ const CATALOG_DATA = [
|
||||
id: "deepagents",
|
||||
title: "DeepAgents",
|
||||
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
|
||||
version: "0.1.20",
|
||||
version: "0.1.19",
|
||||
iconId: "deepagents",
|
||||
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.20"],
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.19"],
|
||||
},
|
||||
{
|
||||
id: "devin",
|
||||
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.2.35",
|
||||
version: "0.2.31",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.2.35", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.2.31", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
title: "Dirac",
|
||||
description:
|
||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||
version: "0.4.21",
|
||||
version: "0.4.17",
|
||||
iconId: "dirac",
|
||||
installLink: "https://dirac.run",
|
||||
command: ["npx", "-y", "dirac-cli@0.4.21", "--acp"],
|
||||
command: ["npx", "-y", "dirac-cli@0.4.17", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.177.0",
|
||||
version: "0.174.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.177.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.174.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
|
||||
id: "fast-agent",
|
||||
title: "fast-agent",
|
||||
description: "Code and build agents with comprehensive multi-provider support",
|
||||
version: "0.9.20",
|
||||
version: "0.9.14",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.20", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.14", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
@@ -192,10 +192,10 @@ const CATALOG_DATA = [
|
||||
title: "GLM Agent",
|
||||
description:
|
||||
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
|
||||
version: "1.3.0",
|
||||
version: "1.1.4",
|
||||
iconId: "glm-acp-agent",
|
||||
installLink: "https://github.com/stefandevo/glm-acp-agent",
|
||||
command: ["npx", "-y", "glm-acp-agent@1.3.0"],
|
||||
command: ["npx", "-y", "glm-acp-agent@1.1.4"],
|
||||
},
|
||||
{
|
||||
id: "goose",
|
||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.29",
|
||||
version: "1.1.27",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.27", "acp"],
|
||||
},
|
||||
{
|
||||
id: "poolside",
|
||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
||||
id: "qoder",
|
||||
title: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
version: "1.1.2",
|
||||
version: "1.0.48",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.2", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.48", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
title: "Qwen Code",
|
||||
description: "Alibaba's Qwen coding assistant",
|
||||
version: "0.20.1",
|
||||
version: "0.19.11",
|
||||
iconId: "qwen-code",
|
||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"],
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.11", "--acp", "--experimental-skills"],
|
||||
},
|
||||
{
|
||||
id: "sigit",
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
type SkillsStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
||||
import { conductorImportSource } from "@/desktop/imports/conductor";
|
||||
import { ExternalImport } from "@/desktop/imports/external-import";
|
||||
|
||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
@@ -201,6 +203,7 @@ export function IntegrationsSection() {
|
||||
onUninstall={handleUninstallSkills}
|
||||
/>
|
||||
</View>
|
||||
<ExternalImport source={conductorImportSource} />
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,11 @@ interface DaemonStatusData {
|
||||
logs: DesktopDaemonLogs;
|
||||
}
|
||||
|
||||
export function useDaemonStatus() {
|
||||
interface UseDaemonStatusOptions {
|
||||
refetchOnMount?: boolean | "always";
|
||||
}
|
||||
|
||||
export function useDaemonStatus(options: UseDaemonStatusOptions = {}) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
@@ -26,7 +30,7 @@ export function useDaemonStatus() {
|
||||
queryKey: DAEMON_STATUS_QUERY_KEY,
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: "always",
|
||||
refetchOnMount: options.refetchOnMount ?? "always",
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
|
||||
@@ -15,8 +13,6 @@ export interface EnableBuiltInDaemonOption {
|
||||
|
||||
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
||||
const isElectron = getIsElectron();
|
||||
const localDaemon = useLocalDaemonServerIdState();
|
||||
const hosts = useHosts();
|
||||
const { settings, updateSettings } = useDesktopSettings();
|
||||
const { data, setStatus, refetch } = useDaemonStatus();
|
||||
const { enable } = useBuiltInDaemonManagement({
|
||||
@@ -27,11 +23,7 @@ export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
||||
refreshStatus: refetch,
|
||||
});
|
||||
|
||||
const isLocalhostConfigured =
|
||||
localDaemon.status === "resolved" &&
|
||||
localDaemon.serverId !== null &&
|
||||
hosts.some((host) => host.serverId === localDaemon.serverId);
|
||||
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
|
||||
const visible = isElectron && data?.status.desktopManaged === false;
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
void (async () => {
|
||||
|
||||
@@ -165,6 +165,19 @@ export interface DesktopInvokeBridge {
|
||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export type DesktopImportOutput =
|
||||
| {
|
||||
runId: string;
|
||||
type: "event";
|
||||
event: { level: "info" | "warning" | "error"; message: string };
|
||||
}
|
||||
| { runId: string; type: "status"; succeeded: boolean };
|
||||
|
||||
export interface DesktopImportsBridge {
|
||||
run?: (input: { source: string }) => Promise<{ runId: string }>;
|
||||
onOutput?: (handler: (output: DesktopImportOutput) => void) => () => void;
|
||||
}
|
||||
|
||||
export interface DesktopHostBridge {
|
||||
platform?: string;
|
||||
invoke?: DesktopInvokeBridge["invoke"];
|
||||
@@ -178,6 +191,7 @@ export interface DesktopHostBridge {
|
||||
webUtils?: DesktopWebUtilsBridge;
|
||||
menu?: DesktopMenuBridge;
|
||||
browser?: DesktopBrowserBridge;
|
||||
imports?: DesktopImportsBridge;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<svg width="115" height="174" viewBox="0 0 115 174" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="36" width="23" height="29">
|
||||
<path d="M0.499023 63.6992V37.251H22.373V63.6992H0.499023Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_41_20251)">
|
||||
<path d="M4.57422 63.6992H22.373V37.251H4.57422C3.58785 37.2511 2.78711 38.0517 2.78711 39.0381V61.9121C2.78725 62.8984 3.58794 63.6991 4.57422 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask1_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="36" width="24" height="29">
|
||||
<path d="M18.7979 63.6992V37.251H40.6719V63.6992H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_41_20251)">
|
||||
<path d="M36.5977 63.6992H18.7988V37.251H36.5977C37.584 37.2511 38.3848 38.0517 38.3848 39.0381V61.9121C38.3846 62.8984 37.5839 63.6991 36.5977 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask2_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="73" width="23" height="28">
|
||||
<path d="M0.499023 100.297V73.8486H22.373V100.297H0.499023Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask2_41_20251)">
|
||||
<path d="M4.57422 100.297H22.373V73.8486H4.57422C3.58785 73.8488 2.78711 74.6493 2.78711 75.6357V98.5098C2.78725 99.496 3.58794 100.297 4.57422 100.297Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask3_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="73" width="24" height="28">
|
||||
<path d="M18.7979 100.297V73.8486H40.6719V100.297H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask3_41_20251)">
|
||||
<path d="M36.5977 100.297H18.7988V73.8486H36.5977C37.584 73.8488 38.3848 74.6493 38.3848 75.6357V98.5098C38.3846 99.496 37.5839 100.297 36.5977 100.297Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask4_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="109" width="23" height="29">
|
||||
<path d="M0.499023 136.896V110.447H22.373V136.896H0.499023Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask4_41_20251)">
|
||||
<path d="M4.57422 136.896H22.373V110.447H4.57422C3.58785 110.447 2.78711 111.248 2.78711 112.234V135.108C2.78725 136.095 3.58794 136.895 4.57422 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask5_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="109" width="24" height="29">
|
||||
<path d="M18.7979 136.896V110.447H40.6719V136.896H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask5_41_20251)">
|
||||
<path d="M36.5977 136.896H18.7988V110.447H36.5977C37.584 110.447 38.3848 111.248 38.3848 112.234V135.108C38.3846 136.095 37.5839 136.895 36.5977 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask6_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="146" width="24" height="28">
|
||||
<path d="M18.7979 173.493V147.045H40.6719V173.493H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask6_41_20251)">
|
||||
<path d="M22.873 173.493H40.6719V147.045H22.873C21.8867 147.045 21.0859 147.846 21.0859 148.832V171.706C21.0861 172.692 21.8868 173.493 22.873 173.493Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask7_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="36" y="146" width="24" height="28">
|
||||
<path d="M37.0967 173.493V147.045H58.9707V173.493H37.0967Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask7_41_20251)">
|
||||
<path d="M37.0967 173.493V147.045H58.9707V173.493H37.0967Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask8_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="54" y="146" width="24" height="28">
|
||||
<path d="M55.3955 173.493V147.045H77.2695V173.493H55.3955Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask8_41_20251)">
|
||||
<path d="M55.3955 173.493V147.045H77.2695V173.493H55.3955Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask9_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="146" width="24" height="28">
|
||||
<path d="M73.6963 173.493V147.045H95.5703V173.493H73.6963Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask9_41_20251)">
|
||||
<path d="M91.4941 173.493H73.6953V147.045H91.4941C92.4805 147.045 93.2812 147.846 93.2812 148.832V171.706C93.2811 172.692 92.4804 173.493 91.4941 173.493Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask10_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="109" width="24" height="29">
|
||||
<path d="M73.6963 136.896V110.447H95.5703V136.896H73.6963Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask10_41_20251)">
|
||||
<path d="M77.7695 136.896H95.5684V110.447H77.7695C76.7832 110.447 75.9824 111.248 75.9824 112.234V135.108C75.9826 136.095 76.7833 136.895 77.7695 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask11_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="109" width="24" height="29">
|
||||
<path d="M91.9932 136.896V110.447H113.867V136.896H91.9932Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask11_41_20251)">
|
||||
<path d="M109.793 136.896H91.9941V110.447H109.793C110.779 110.447 111.58 111.248 111.58 112.234V135.108C111.58 136.095 110.779 136.895 109.793 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask12_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="0" width="24" height="28">
|
||||
<path d="M18.7979 27.1006V0.652344H40.6719V27.1006H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask12_41_20251)">
|
||||
<path d="M22.873 27.1006H40.6719V0.652344H22.873C21.8867 0.652488 21.0859 1.45305 21.0859 2.43945V25.3135C21.0861 26.2998 21.8868 27.1004 22.873 27.1006Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask13_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="36" y="0" width="24" height="28">
|
||||
<path d="M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask13_41_20251)">
|
||||
<path d="M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask14_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="54" y="0" width="24" height="28">
|
||||
<path d="M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask14_41_20251)">
|
||||
<path d="M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask15_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="0" width="24" height="28">
|
||||
<path d="M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask15_41_20251)">
|
||||
<path d="M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask16_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="0" width="24" height="28">
|
||||
<path d="M91.9932 27.1006V0.652344H113.867V27.1006H91.9932Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask16_41_20251)">
|
||||
<path d="M109.793 27.1006H91.9941V0.652344H109.793C110.779 0.652488 111.58 1.45305 111.58 2.43945V25.3135C111.58 26.2998 110.779 27.1004 109.793 27.1006Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask17_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="36" width="24" height="29">
|
||||
<path d="M73.6963 63.6992V37.251H95.5703V63.6992H73.6963Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask17_41_20251)">
|
||||
<path d="M77.7695 63.6992H95.5684V37.251H77.7695C76.7832 37.2511 75.9824 38.0517 75.9824 39.0381V61.9121C75.9826 62.8984 76.7833 63.6991 77.7695 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask18_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="36" width="24" height="29">
|
||||
<path d="M91.9932 63.6992V37.251H113.867V63.6992H91.9932Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask18_41_20251)">
|
||||
<path d="M109.793 63.6992H91.9941V37.251H109.793C110.779 37.2511 111.58 38.0517 111.58 39.0381V61.9121C111.58 62.8984 110.779 63.6991 109.793 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1,8 @@
|
||||
import type { ImportSourceDescriptor } from "./import-sheet";
|
||||
|
||||
export const conductorImportSource: ImportSourceDescriptor = {
|
||||
id: "conductor",
|
||||
title: "Conductor",
|
||||
// Official light letter mark from https://www.conductor.build/brandkit.
|
||||
icon: require("./conductor.svg"),
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Image, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isElectronRuntimeMac } from "@/desktop/host";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ImportSheet, type ImportSourceDescriptor } from "./import-sheet";
|
||||
|
||||
export function ExternalImport({ source }: { source: ImportSourceDescriptor }) {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useDaemonStatus({ refetchOnMount: false });
|
||||
const desktopManaged = data?.status.desktopManaged;
|
||||
const [visible, setVisible] = useState(false);
|
||||
const open = useCallback(() => setVisible(true), []);
|
||||
const close = useCallback(() => setVisible(false), []);
|
||||
if (!isElectronRuntimeMac()) return null;
|
||||
|
||||
const available = desktopManaged === true;
|
||||
let reason: string | null = null;
|
||||
if (desktopManaged === undefined) {
|
||||
reason = t("desktop.integrations.import.availability.checking");
|
||||
} else if (!available) {
|
||||
reason = t("desktop.integrations.import.availability.host-not-running");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
style={[settingsStyles.row, settingsStyles.rowBorder]}
|
||||
testID={`${source.id}-import-row`}
|
||||
>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<View style={styles.iconFrame}>
|
||||
<Image source={source.icon} style={styles.icon} resizeMode="contain" />
|
||||
</View>
|
||||
<Text style={settingsStyles.rowTitle}>{source.title}</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{reason ?? t("desktop.integrations.import.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={open}
|
||||
disabled={!available}
|
||||
testID={`${source.id}-import-open`}
|
||||
>
|
||||
{t("desktop.integrations.import.actions.import")}
|
||||
</Button>
|
||||
</View>
|
||||
<ImportSheet source={source} visible={visible} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
iconFrame: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: "#282423",
|
||||
},
|
||||
icon: { width: 12, height: 18 },
|
||||
}));
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ImageSourcePropType } from "react-native";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getDesktopHost, type DesktopImportOutput } from "@/desktop/host";
|
||||
|
||||
type ImportState =
|
||||
| { status: "confirm" }
|
||||
| { status: "running"; runId: string | null; output: string }
|
||||
| { status: "complete"; succeeded: boolean; output: string }
|
||||
| { status: "failed"; message: string; output: string };
|
||||
|
||||
export interface ImportSourceDescriptor {
|
||||
id: string;
|
||||
icon: ImageSourcePropType;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function ImportSheet({
|
||||
source,
|
||||
visible,
|
||||
onClose,
|
||||
}: {
|
||||
source: ImportSourceDescriptor;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<ImportState>({ status: "confirm" });
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("desktop.integrations.import.sheetTitle", { source: source.title }) }),
|
||||
[source.title, t],
|
||||
);
|
||||
const close = useCallback(() => {
|
||||
if (state.status !== "running") onClose();
|
||||
}, [onClose, state.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) setState({ status: "confirm" });
|
||||
}, [visible]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const bridge = getDesktopHost()?.imports;
|
||||
if (!bridge?.run || !bridge.onOutput) {
|
||||
setState({
|
||||
status: "failed",
|
||||
message: t("desktop.integrations.import.unavailable"),
|
||||
output: "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState({ status: "running", runId: null, output: "" });
|
||||
let runId: string | null = null;
|
||||
const unsubscribe = bridge.onOutput((event: DesktopImportOutput) => {
|
||||
if (runId && event.runId !== runId) return;
|
||||
setState((current) => reduceOutput(current, event));
|
||||
if (event.type === "status") unsubscribe();
|
||||
});
|
||||
try {
|
||||
const started = await bridge.run({ source: source.id });
|
||||
runId = started.runId;
|
||||
setState((current) =>
|
||||
current.status === "running" ? { ...current, runId: started.runId } : current,
|
||||
);
|
||||
} catch (error) {
|
||||
unsubscribe();
|
||||
setState({
|
||||
status: "failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
output: "",
|
||||
});
|
||||
}
|
||||
}, [source.id, t]);
|
||||
|
||||
const footer = useMemo(() => {
|
||||
if (state.status === "confirm") {
|
||||
return (
|
||||
<Button onPress={start} testID="import-confirm">
|
||||
{t("desktop.integrations.import.actions.import")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (state.status === "running") {
|
||||
return <Button disabled>{t("desktop.integrations.import.actions.importing")}</Button>;
|
||||
}
|
||||
return (
|
||||
<Button onPress={close} testID="import-done">
|
||||
{t("desktop.integrations.import.actions.done")}
|
||||
</Button>
|
||||
);
|
||||
}, [close, start, state.status, t]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={close}
|
||||
footer={footer}
|
||||
desktopMaxWidth={640}
|
||||
testID="import-sheet"
|
||||
dismissible={state.status !== "running"}
|
||||
>
|
||||
{state.status === "confirm" ? (
|
||||
<Text style={styles.copy}>
|
||||
{t("desktop.integrations.import.confirmation", { source: source.title })}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
{state.status === "complete" ? (
|
||||
<Text testID="import-result" style={styles.copy}>
|
||||
{state.succeeded
|
||||
? t("desktop.integrations.import.complete")
|
||||
: t("desktop.integrations.import.failed")}
|
||||
</Text>
|
||||
) : null}
|
||||
{state.status === "failed" ? (
|
||||
<Text testID="import-error" style={styles.error}>
|
||||
{state.message}
|
||||
</Text>
|
||||
) : null}
|
||||
<ScrollView style={styles.output}>
|
||||
<Text selectable testID="import-output" style={styles.outputText}>
|
||||
{state.output}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function reduceOutput(state: ImportState, event: DesktopImportOutput): ImportState {
|
||||
if (state.status !== "running") return state;
|
||||
if (event.type === "status") {
|
||||
return { status: "complete", succeeded: event.succeeded, output: state.output };
|
||||
}
|
||||
const line = `${event.event.level.toUpperCase()}: ${event.event.message}\n`;
|
||||
return { ...state, output: `${state.output}${line}` };
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
content: { gap: theme.spacing[3] },
|
||||
copy: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||
error: { color: theme.colors.statusDanger, fontSize: theme.fontSize.sm },
|
||||
output: {
|
||||
minHeight: 180,
|
||||
maxHeight: 360,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
outputText: { color: theme.colors.foregroundMuted, fontFamily: "monospace", fontSize: 12 },
|
||||
}));
|
||||
@@ -1,178 +0,0 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { FileConflictAlert } from "./conflict-alert";
|
||||
import type { FileEditorStatus } from "./editor/model";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
||||
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export function FilePanelBar({
|
||||
size,
|
||||
lineCount,
|
||||
mode,
|
||||
onModeChange,
|
||||
editorStatus,
|
||||
cursor,
|
||||
vimMode,
|
||||
conflictUnavailable,
|
||||
onOverwrite,
|
||||
onReload,
|
||||
}: {
|
||||
size: number;
|
||||
lineCount?: number;
|
||||
mode?: "preview" | "source";
|
||||
onModeChange?(mode: "preview" | "source"): void;
|
||||
editorStatus?: FileEditorStatus;
|
||||
cursor?: { line: number; column: number };
|
||||
vimMode?: string | null;
|
||||
conflictUnavailable?: boolean;
|
||||
onOverwrite?(): void;
|
||||
onReload?(): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const markdownModes = [
|
||||
{
|
||||
value: "preview" as const,
|
||||
label: t("panels.file.editor.preview"),
|
||||
testID: "file-mode-preview",
|
||||
},
|
||||
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
|
||||
];
|
||||
return (
|
||||
<View style={styles.chrome} testID="file-panel-bar">
|
||||
<View style={styles.row}>
|
||||
<View style={styles.metadata}>
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
|
||||
>
|
||||
{formatFileSize(size)}
|
||||
</Text>
|
||||
{lineCount !== undefined ? (
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
|
||||
>
|
||||
{t("panels.file.editor.lines", { count: lineCount })}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View
|
||||
style={styles.status}
|
||||
accessibilityLabel={
|
||||
editorStatus
|
||||
? t("panels.file.editor.editorStatus", { status: editorStatus })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{editorStatus === "dirty" ? (
|
||||
<View
|
||||
style={styles.dirtyDot}
|
||||
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
|
||||
/>
|
||||
) : null}
|
||||
{editorStatus === "saving" ? (
|
||||
<>
|
||||
<ThemedSpinner size={14} uniProps={spinnerMapping} />
|
||||
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{editorStatus === "error" ? (
|
||||
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
|
||||
) : null}
|
||||
{editorStatus === "conflict" ? (
|
||||
<Text style={styles.error}>{t("panels.file.editor.changedOnDisk")}</Text>
|
||||
) : null}
|
||||
{vimMode ? (
|
||||
<Text
|
||||
style={styles.vim}
|
||||
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
|
||||
>
|
||||
{vimMode}
|
||||
</Text>
|
||||
) : null}
|
||||
{cursor ? (
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
|
||||
>
|
||||
Ln {cursor.line}, Col {cursor.column}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{mode && onModeChange ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={mode}
|
||||
onValueChange={onModeChange}
|
||||
testID="file-markdown-mode"
|
||||
options={markdownModes}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{editorStatus === "conflict" && onOverwrite && onReload ? (
|
||||
<View style={styles.notice}>
|
||||
<FileConflictAlert
|
||||
unavailable={conflictUnavailable ?? false}
|
||||
onOverwrite={onOverwrite}
|
||||
onReload={onReload}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
chrome: {
|
||||
flexShrink: 0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
row: {
|
||||
minHeight: 32,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
metadata: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs },
|
||||
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.xs },
|
||||
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs },
|
||||
dirtyDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
status: {
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
vim: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
notice: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[3] },
|
||||
}));
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function FileConflictAlert({
|
||||
unavailable,
|
||||
onOverwrite,
|
||||
onReload,
|
||||
}: {
|
||||
unavailable: boolean;
|
||||
onOverwrite(): void;
|
||||
onReload(): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Alert
|
||||
variant="warning"
|
||||
title={
|
||||
unavailable
|
||||
? t("panels.file.editor.unavailableTitle")
|
||||
: t("panels.file.editor.changedOnDisk")
|
||||
}
|
||||
description={t("panels.file.editor.conflictDescription")}
|
||||
testID="file-conflict-alert"
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={onOverwrite} disabled={unavailable}>
|
||||
{t("panels.file.editor.overwrite")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={onReload} disabled={unavailable}>
|
||||
{t("panels.file.editor.reload")}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import {
|
||||
bracketMatching,
|
||||
defaultHighlightStyle,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language";
|
||||
import { searchKeymap } from "@codemirror/search";
|
||||
import {
|
||||
EditorView,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from "@codemirror/view";
|
||||
import { createCodeMirrorHighlightStyle, type HighlightStyle } from "@getpaseo/highlight";
|
||||
|
||||
export interface EditorVisualTheme {
|
||||
colorScheme: "light" | "dark";
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor: string;
|
||||
foregroundMuted: string;
|
||||
border: string;
|
||||
selection: string;
|
||||
monoFont: string;
|
||||
codeFontSize: number;
|
||||
syntax: Record<HighlightStyle, string>;
|
||||
}
|
||||
|
||||
export function editorBaseExtensions(onSave: () => void) {
|
||||
return [
|
||||
lineNumbers(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
highlightActiveLine(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
keymap.of([
|
||||
{ key: "Mod-s", preventDefault: true, run: () => (onSave(), true) },
|
||||
indentWithTab,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export function editorTheme(theme: EditorVisualTheme) {
|
||||
return [
|
||||
EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
height: "100%",
|
||||
backgroundColor: theme.background,
|
||||
color: theme.foreground,
|
||||
fontFamily: theme.monoFont,
|
||||
fontSize: `${theme.codeFontSize}px`,
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
fontFamily: theme.monoFont,
|
||||
lineHeight: "1.45",
|
||||
},
|
||||
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
|
||||
".cm-gutters": {
|
||||
backgroundColor: theme.background,
|
||||
color: theme.foregroundMuted,
|
||||
borderRight: `1px solid ${theme.border}`,
|
||||
},
|
||||
".cm-activeLine": { backgroundColor: "transparent" },
|
||||
".cm-activeLineGutter": { backgroundColor: "transparent", color: theme.foreground },
|
||||
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
|
||||
backgroundColor: theme.selection,
|
||||
},
|
||||
".cm-selectionBackground, ::selection": {
|
||||
backgroundColor: theme.selection,
|
||||
},
|
||||
"&.cm-focused": { outline: "none" },
|
||||
},
|
||||
{ dark: theme.colorScheme === "dark" },
|
||||
),
|
||||
syntaxHighlighting(createCodeMirrorHighlightStyle(theme.syntax)),
|
||||
];
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
FileEditorModel,
|
||||
type FileEditorClock,
|
||||
type FileEditorFile,
|
||||
type FileEditorSession,
|
||||
} from "./model";
|
||||
|
||||
class TestClock implements FileEditorClock {
|
||||
private callback: (() => void) | null = null;
|
||||
|
||||
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
|
||||
this.callback = callback;
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
clearTimeout(): void {
|
||||
this.callback = null;
|
||||
}
|
||||
|
||||
fire(): void {
|
||||
const callback = this.callback;
|
||||
this.callback = null;
|
||||
callback?.();
|
||||
}
|
||||
}
|
||||
|
||||
class FileSession implements FileEditorSession {
|
||||
file: FileEditorFile;
|
||||
writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> = [];
|
||||
nextWrite: FileWriteResult | Error | null = null;
|
||||
private pendingWrite: Promise<FileWriteResult> | null = null;
|
||||
private resolvePendingWrite: ((result: FileWriteResult) => void) | null = null;
|
||||
|
||||
constructor(file: FileEditorFile) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
async read(): Promise<FileEditorFile> {
|
||||
return this.file;
|
||||
}
|
||||
|
||||
async write(input: {
|
||||
content: string;
|
||||
expectedModifiedAt: string;
|
||||
expectedRevision?: string;
|
||||
}): Promise<FileWriteResult> {
|
||||
this.writes.push(input);
|
||||
if (this.pendingWrite) return this.pendingWrite;
|
||||
if (this.nextWrite instanceof Error) throw this.nextWrite;
|
||||
if (this.nextWrite) return this.nextWrite;
|
||||
return {
|
||||
status: "written",
|
||||
modifiedAt: "2026-07-18T00:00:01.000Z",
|
||||
size: input.content.length,
|
||||
};
|
||||
}
|
||||
|
||||
holdNextWrite(): void {
|
||||
this.pendingWrite = new Promise((resolve) => {
|
||||
this.resolvePendingWrite = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
finishHeldWrite(result: FileWriteResult): void {
|
||||
this.resolvePendingWrite?.(result);
|
||||
this.pendingWrite = null;
|
||||
this.resolvePendingWrite = null;
|
||||
}
|
||||
}
|
||||
|
||||
function ready(
|
||||
modifiedAt = "2026-07-18T00:00:00.000Z",
|
||||
size = 3,
|
||||
): Extract<FileVersion, { status: "ready" }> {
|
||||
return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt };
|
||||
}
|
||||
|
||||
function makeModel() {
|
||||
const file = { content: "one", version: ready() as Extract<FileVersion, { status: "ready" }> };
|
||||
const session = new FileSession(file);
|
||||
const clock = new TestClock();
|
||||
return { model: new FileEditorModel({ file, session, clock }), session, clock };
|
||||
}
|
||||
|
||||
describe("FileEditorModel", () => {
|
||||
test("tracks whether the current buffer differs from persisted content", async () => {
|
||||
const { model } = makeModel();
|
||||
|
||||
expect(model.getSnapshot().modified).toBe(false);
|
||||
model.edit("two");
|
||||
expect(model.getSnapshot().modified).toBe(true);
|
||||
model.edit("one");
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
|
||||
|
||||
model.edit("saved");
|
||||
await model.save();
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
|
||||
});
|
||||
|
||||
test("adopts a precise revision for otherwise unchanged initial metadata", () => {
|
||||
const { model } = makeModel();
|
||||
|
||||
model.receiveFileVersion({ ...ready(), revision: "precise-revision" });
|
||||
|
||||
expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" });
|
||||
});
|
||||
|
||||
test("keeps a newer edit modified when an older save finishes", async () => {
|
||||
const { model, session } = makeModel();
|
||||
session.holdNextWrite();
|
||||
model.edit("saving");
|
||||
|
||||
const save = model.save();
|
||||
model.edit("newer edit");
|
||||
session.finishHeldWrite({
|
||||
status: "written",
|
||||
modifiedAt: "2026-07-18T00:00:01.000Z",
|
||||
size: 6,
|
||||
});
|
||||
await save;
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
status: "dirty",
|
||||
content: "newer edit",
|
||||
modified: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("autosaves the latest edit after inactivity", async () => {
|
||||
const { model, session, clock } = makeModel();
|
||||
|
||||
model.edit("two");
|
||||
model.edit("three");
|
||||
clock.fire();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(session.writes).toEqual([
|
||||
{ content: "three", expectedModifiedAt: "2026-07-18T00:00:00.000Z" },
|
||||
]);
|
||||
expect(model.getSnapshot().status).toBe("clean");
|
||||
});
|
||||
|
||||
test("reloads a clean editor when the disk version changes", async () => {
|
||||
const { model, session } = makeModel();
|
||||
session.file = {
|
||||
content: "external",
|
||||
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
|
||||
};
|
||||
|
||||
model.receiveFileVersion(session.file.version);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" });
|
||||
});
|
||||
|
||||
test("coalesces consecutive clean disk updates onto the latest reload", async () => {
|
||||
const { model, session } = makeModel();
|
||||
const reads: Array<(file: FileEditorFile) => void> = [];
|
||||
session.read = () => new Promise((resolve) => reads.push(resolve));
|
||||
const firstVersion = ready("2026-07-18T00:00:02.000Z", 5);
|
||||
const latestVersion = ready("2026-07-18T00:00:03.000Z", 6);
|
||||
|
||||
model.receiveFileVersion(firstVersion);
|
||||
model.receiveFileVersion(latestVersion);
|
||||
reads[0]?.({ content: "first", version: firstVersion });
|
||||
await Promise.resolve();
|
||||
reads[1]?.({ content: "latest", version: latestVersion });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" });
|
||||
});
|
||||
|
||||
test("preserves a dirty buffer and overwrites against the newest disk revision", async () => {
|
||||
const { model, session } = makeModel();
|
||||
model.edit("local");
|
||||
model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4));
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" });
|
||||
await model.overwrite();
|
||||
|
||||
expect(session.writes).toEqual([
|
||||
{ content: "local", expectedModifiedAt: "2026-07-18T00:00:02.000Z" },
|
||||
]);
|
||||
expect(model.getSnapshot().status).toBe("clean");
|
||||
});
|
||||
|
||||
test("reload discards a conflicted local buffer for the disk contents", async () => {
|
||||
const { model, session } = makeModel();
|
||||
model.edit("local");
|
||||
const diskVersion = ready("2026-07-18T00:00:02.000Z", 4) as Extract<
|
||||
FileVersion,
|
||||
{ status: "ready" }
|
||||
>;
|
||||
session.file = { content: "disk", version: diskVersion };
|
||||
model.receiveFileVersion(diskVersion);
|
||||
|
||||
await model.reload();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
|
||||
});
|
||||
|
||||
test("reports failed saves without losing the local buffer", async () => {
|
||||
const { model, session } = makeModel();
|
||||
session.nextWrite = new Error("disk full");
|
||||
model.edit("important local work");
|
||||
|
||||
await model.save();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
status: "error",
|
||||
content: "important local work",
|
||||
error: "disk full",
|
||||
});
|
||||
});
|
||||
|
||||
test("a deletion conflicts with local changes and stops autosave", () => {
|
||||
const { model, session, clock } = makeModel();
|
||||
model.edit("local");
|
||||
model.receiveFileVersion({ status: "missing", cwd: "/workspace", path: "file.ts" });
|
||||
|
||||
clock.fire();
|
||||
|
||||
expect(model.getSnapshot().status).toBe("conflict");
|
||||
expect(session.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("dispose cancels pending autosave", () => {
|
||||
const { model, session, clock } = makeModel();
|
||||
model.edit("local");
|
||||
|
||||
model.dispose();
|
||||
clock.fire();
|
||||
|
||||
expect(session.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("suspends a pending autosave while close confirmation is active", async () => {
|
||||
const { model, session, clock } = makeModel();
|
||||
model.edit("local");
|
||||
|
||||
const resume = model.suspendAutosave();
|
||||
clock.fire();
|
||||
expect(session.writes).toEqual([]);
|
||||
|
||||
resume();
|
||||
clock.fire();
|
||||
await Promise.resolve();
|
||||
expect(session.writes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type FileEditorStatus = "loading" | "clean" | "dirty" | "saving" | "conflict" | "error";
|
||||
|
||||
export interface FileEditorSnapshot {
|
||||
status: FileEditorStatus;
|
||||
content: string;
|
||||
modified: boolean;
|
||||
version: FileVersion;
|
||||
observedVersion: FileVersion;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FileEditorFile {
|
||||
content: string;
|
||||
version: Extract<FileVersion, { status: "ready" }>;
|
||||
}
|
||||
|
||||
export interface FileEditorSession {
|
||||
read(): Promise<FileEditorFile>;
|
||||
write(input: {
|
||||
content: string;
|
||||
expectedModifiedAt: string;
|
||||
expectedRevision?: string;
|
||||
}): Promise<FileWriteResult>;
|
||||
}
|
||||
|
||||
export interface FileEditorClock {
|
||||
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
||||
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
||||
}
|
||||
|
||||
const systemClock: FileEditorClock = {
|
||||
setTimeout(callback, delay) {
|
||||
return globalThis.setTimeout(callback, delay);
|
||||
},
|
||||
clearTimeout(handle) {
|
||||
globalThis.clearTimeout(handle);
|
||||
},
|
||||
};
|
||||
|
||||
export class FileEditorModel {
|
||||
private readonly session: FileEditorSession;
|
||||
private readonly clock: FileEditorClock;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private snapshot: FileEditorSnapshot;
|
||||
private autosave: ReturnType<typeof setTimeout> | null = null;
|
||||
private saveSequence = 0;
|
||||
private disposed = false;
|
||||
private observedWhileSaving: FileVersion | null = null;
|
||||
private persistedContent: string;
|
||||
|
||||
constructor(input: {
|
||||
file: FileEditorFile;
|
||||
session: FileEditorSession;
|
||||
clock?: FileEditorClock;
|
||||
}) {
|
||||
this.session = input.session;
|
||||
this.clock = input.clock ?? systemClock;
|
||||
this.persistedContent = input.file.content;
|
||||
this.snapshot = {
|
||||
status: "clean",
|
||||
content: input.file.content,
|
||||
modified: false,
|
||||
version: input.file.version,
|
||||
observedVersion: input.file.version,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
getSnapshot = (): FileEditorSnapshot => this.snapshot;
|
||||
|
||||
edit(content: string): void {
|
||||
if (this.disposed || content === this.snapshot.content) return;
|
||||
const modified = content !== this.persistedContent;
|
||||
let status: FileEditorStatus = modified ? "dirty" : "clean";
|
||||
if (this.snapshot.status === "conflict" || this.snapshot.status === "loading") {
|
||||
status = "conflict";
|
||||
}
|
||||
this.setSnapshot({ ...this.snapshot, status, content, modified, error: null });
|
||||
if (status === "dirty") this.scheduleAutosave();
|
||||
else this.clearAutosave();
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (this.disposed || (this.snapshot.status !== "dirty" && this.snapshot.status !== "error")) {
|
||||
return;
|
||||
}
|
||||
if (this.snapshot.observedVersion.status !== "ready") {
|
||||
this.enterConflict(this.snapshot.observedVersion);
|
||||
return;
|
||||
}
|
||||
await this.performWrite(this.snapshot.observedVersion);
|
||||
}
|
||||
|
||||
receiveFileVersion(version: FileVersion): void {
|
||||
if (this.disposed) return;
|
||||
if (sameVersion(version, this.snapshot.observedVersion)) {
|
||||
if (
|
||||
version.status === "ready" &&
|
||||
this.snapshot.observedVersion.status === "ready" &&
|
||||
version.revision &&
|
||||
!this.snapshot.observedVersion.revision
|
||||
) {
|
||||
this.setSnapshot({
|
||||
...this.snapshot,
|
||||
version:
|
||||
this.snapshot.version.status === "ready"
|
||||
? { ...this.snapshot.version, revision: version.revision }
|
||||
: this.snapshot.version,
|
||||
observedVersion: version,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.setSnapshot({ ...this.snapshot, observedVersion: version });
|
||||
if (this.snapshot.status === "saving") {
|
||||
this.observedWhileSaving = version;
|
||||
return;
|
||||
}
|
||||
if (this.snapshot.status === "clean" || this.snapshot.status === "loading") {
|
||||
void this.reloadFromDisk(version);
|
||||
return;
|
||||
}
|
||||
this.enterConflict(version);
|
||||
}
|
||||
|
||||
async overwrite(): Promise<void> {
|
||||
if (this.disposed || this.snapshot.status !== "conflict") return;
|
||||
if (this.snapshot.observedVersion.status !== "ready") return;
|
||||
await this.performWrite(this.snapshot.observedVersion);
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
await this.reloadFromDisk(this.snapshot.observedVersion);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.saveSequence += 1;
|
||||
this.clearAutosave();
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
suspendAutosave(): () => void {
|
||||
const wasScheduled = this.autosave !== null;
|
||||
this.clearAutosave();
|
||||
let resumed = false;
|
||||
return () => {
|
||||
if (resumed || this.disposed) return;
|
||||
resumed = true;
|
||||
if (wasScheduled && this.snapshot.status === "dirty") this.scheduleAutosave();
|
||||
};
|
||||
}
|
||||
|
||||
private async performWrite(
|
||||
expectedVersion: Extract<FileVersion, { status: "ready" }>,
|
||||
): Promise<void> {
|
||||
this.clearAutosave();
|
||||
const sequence = ++this.saveSequence;
|
||||
const content = this.snapshot.content;
|
||||
this.observedWhileSaving = null;
|
||||
this.setSnapshot({ ...this.snapshot, status: "saving", error: null });
|
||||
let result: FileWriteResult;
|
||||
try {
|
||||
result = await this.session.write({
|
||||
content,
|
||||
expectedModifiedAt: expectedVersion.modifiedAt,
|
||||
expectedRevision: expectedVersion.revision,
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.disposed || sequence !== this.saveSequence) return;
|
||||
this.setSnapshot({
|
||||
...this.snapshot,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.disposed || sequence !== this.saveSequence) return;
|
||||
if (result.status === "error") {
|
||||
this.setSnapshot({ ...this.snapshot, status: "error", error: result.error });
|
||||
return;
|
||||
}
|
||||
if (result.status === "conflict") {
|
||||
this.enterConflict(result.version);
|
||||
return;
|
||||
}
|
||||
|
||||
const writtenVersion: FileVersion = {
|
||||
status: "ready",
|
||||
cwd: this.snapshot.version.cwd,
|
||||
path: this.snapshot.version.path,
|
||||
size: result.size,
|
||||
modifiedAt: result.modifiedAt,
|
||||
revision: result.revision,
|
||||
};
|
||||
const pending = this.observedWhileSaving;
|
||||
this.observedWhileSaving = null;
|
||||
this.persistedContent = content;
|
||||
if (pending && !sameVersion(pending, writtenVersion)) {
|
||||
this.setSnapshot({
|
||||
...this.snapshot,
|
||||
status: "conflict",
|
||||
modified: this.snapshot.content !== this.persistedContent,
|
||||
version: writtenVersion,
|
||||
observedVersion: pending,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const modified = this.snapshot.content !== this.persistedContent;
|
||||
this.setSnapshot({
|
||||
...this.snapshot,
|
||||
status: modified ? "dirty" : "clean",
|
||||
modified,
|
||||
version: writtenVersion,
|
||||
observedVersion: writtenVersion,
|
||||
error: null,
|
||||
});
|
||||
if (modified) this.scheduleAutosave();
|
||||
}
|
||||
|
||||
private async reloadFromDisk(version: FileVersion): Promise<void> {
|
||||
this.clearAutosave();
|
||||
if (version.status !== "ready") {
|
||||
this.enterConflict(version);
|
||||
return;
|
||||
}
|
||||
const sequence = ++this.saveSequence;
|
||||
this.setSnapshot({ ...this.snapshot, status: "loading", error: null });
|
||||
try {
|
||||
const file = await this.session.read();
|
||||
if (this.disposed || sequence !== this.saveSequence || this.snapshot.status !== "loading") {
|
||||
return;
|
||||
}
|
||||
this.persistedContent = file.content;
|
||||
this.setSnapshot({
|
||||
status: "clean",
|
||||
content: file.content,
|
||||
modified: false,
|
||||
version: file.version,
|
||||
observedVersion: file.version,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.disposed || sequence !== this.saveSequence) return;
|
||||
this.setSnapshot({
|
||||
...this.snapshot,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private enterConflict(version: FileVersion): void {
|
||||
this.clearAutosave();
|
||||
this.setSnapshot({
|
||||
...this.snapshot,
|
||||
status: "conflict",
|
||||
modified: this.snapshot.content !== this.persistedContent,
|
||||
observedVersion: version,
|
||||
error: version.status === "error" ? version.error : null,
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleAutosave(): void {
|
||||
this.clearAutosave();
|
||||
this.autosave = this.clock.setTimeout(() => {
|
||||
this.autosave = null;
|
||||
void this.save();
|
||||
}, 800);
|
||||
}
|
||||
|
||||
private clearAutosave(): void {
|
||||
if (!this.autosave) return;
|
||||
this.clock.clearTimeout(this.autosave);
|
||||
this.autosave = null;
|
||||
}
|
||||
|
||||
private setSnapshot(snapshot: FileEditorSnapshot): void {
|
||||
this.snapshot = snapshot;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function sameVersion(left: FileVersion, right: FileVersion): boolean {
|
||||
if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path)
|
||||
return false;
|
||||
if (left.status === "ready" && right.status === "ready") {
|
||||
if (left.revision && right.revision) return left.revision === right.revision;
|
||||
return left.modifiedAt === right.modifiedAt && left.size === right.size;
|
||||
}
|
||||
if (left.status === "error" && right.status === "error") return left.error === right.error;
|
||||
return true;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { HighlightStyle } from "@getpaseo/highlight";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { FileEditorModel } from "./model";
|
||||
|
||||
export function FileEditorView(_props: {
|
||||
model: FileEditorModel;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
vimEnabled: boolean;
|
||||
theme: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
foregroundMuted: string;
|
||||
border: string;
|
||||
selection: string;
|
||||
monoFont: string;
|
||||
codeFontSize: number;
|
||||
syntax: Record<HighlightStyle, string>;
|
||||
};
|
||||
onCursorChange(position: { line: number; column: number }): void;
|
||||
onVimModeChange(mode: string | null): void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Source editing is available on web and desktop.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
}));
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
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 type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { FileEditorModel } from "./model";
|
||||
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
|
||||
|
||||
interface FileEditorViewProps {
|
||||
model: FileEditorModel;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
vimEnabled: boolean;
|
||||
theme: EditorVisualTheme;
|
||||
onCursorChange(position: { line: number; column: number }): void;
|
||||
onVimModeChange(mode: string | null): void;
|
||||
}
|
||||
|
||||
const languageCompartment = new Compartment();
|
||||
const themeCompartment = new Compartment();
|
||||
const vimCompartment = new Compartment();
|
||||
|
||||
export function FileEditorView({
|
||||
model,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
vimEnabled,
|
||||
theme,
|
||||
onCursorChange,
|
||||
onVimModeChange,
|
||||
}: FileEditorViewProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content });
|
||||
const onCursorChangeRef = useRef(onCursorChange);
|
||||
onCursorChangeRef.current = onCursorChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return;
|
||||
const values = initial.current;
|
||||
const view = new EditorView({
|
||||
parent: hostRef.current,
|
||||
state: EditorState.create({
|
||||
doc: values.content,
|
||||
extensions: [
|
||||
vimCompartment.of(values.vimEnabled ? vim() : []),
|
||||
...editorBaseExtensions(() => void values.model.save()),
|
||||
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
|
||||
themeCompartment.of(editorTheme(values.theme)),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (
|
||||
update.docChanged &&
|
||||
!update.transactions.some((tr) => tr.annotation(remoteUpdate))
|
||||
) {
|
||||
values.model.edit(update.state.doc.toString());
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
const head = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
onCursorChangeRef.current({ line: line.number, column: head - line.from + 1 });
|
||||
}
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
viewRef.current = view;
|
||||
onCursorChangeRef.current({ line: 1, column: 1 });
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || view.state.doc.toString() === snapshot.content) return;
|
||||
const head = Math.min(view.state.selection.main.head, snapshot.content.length);
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: snapshot.content },
|
||||
selection: { anchor: head },
|
||||
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
|
||||
});
|
||||
}, [snapshot.content]);
|
||||
|
||||
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;
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
}, [location.lineEnd, location.lineStart, navigationRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
effects: languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []),
|
||||
});
|
||||
}, [filename]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(editorTheme(theme)) });
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({ effects: vimCompartment.reconfigure(vimEnabled ? vim() : []) });
|
||||
if (!vimEnabled) {
|
||||
onVimModeChange(null);
|
||||
return;
|
||||
}
|
||||
const cm = getCM(view);
|
||||
if (!cm) return;
|
||||
function handleModeChange(event: { mode?: string }) {
|
||||
onVimModeChange((event.mode ?? "normal").toUpperCase());
|
||||
}
|
||||
cm.on("vim-mode-change", handleModeChange);
|
||||
onVimModeChange("NORMAL");
|
||||
return () => cm.off("vim-mode-change", handleModeChange);
|
||||
}, [onVimModeChange, vimEnabled]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
data-testid="file-source-editor"
|
||||
aria-label={`Source editor for ${filename}`}
|
||||
style={HOST_STYLE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const remoteUpdate = Annotation.define<boolean>();
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { FileVersion } from "@getpaseo/protocol/messages";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
|
||||
export function useLiveFile(input: {
|
||||
client: DaemonClient | null;
|
||||
serverId: string;
|
||||
cwd: string | null;
|
||||
path: string | null;
|
||||
enabled: boolean;
|
||||
liveUpdates: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [subscriptionReady, setSubscriptionReady] = useState(!input.liveUpdates);
|
||||
const [version, setVersion] = useState<FileVersion | null>(null);
|
||||
const latestVersion = useRef<FileVersion | null>(null);
|
||||
const queryKey = useMemo(
|
||||
() => ["workspaceFile", input.serverId, input.cwd, input.path] as const,
|
||||
[input.cwd, input.path, input.serverId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
latestVersion.current = null;
|
||||
setVersion(null);
|
||||
const { client, cwd, path } = input;
|
||||
if (!input.liveUpdates || !client || !cwd || !path || !input.enabled) {
|
||||
setSubscriptionReady(!input.liveUpdates);
|
||||
return;
|
||||
}
|
||||
let disposed = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
setSubscriptionReady(false);
|
||||
void (async () => {
|
||||
try {
|
||||
const subscription = await client.subscribeFile({ cwd, path }, (next) => {
|
||||
if (disposed) return;
|
||||
latestVersion.current = next;
|
||||
setVersion(next);
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
});
|
||||
if (disposed) {
|
||||
subscription.unsubscribe();
|
||||
return;
|
||||
}
|
||||
unsubscribe = subscription.unsubscribe;
|
||||
latestVersion.current = subscription.initial;
|
||||
setVersion(subscription.initial);
|
||||
setSubscriptionReady(true);
|
||||
} catch {
|
||||
if (!disposed) setSubscriptionReady(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [
|
||||
input.client,
|
||||
input.cwd,
|
||||
input.enabled,
|
||||
input.liveUpdates,
|
||||
input.path,
|
||||
queryClient,
|
||||
queryKey,
|
||||
input.serverId,
|
||||
]);
|
||||
|
||||
const query = useFetchQuery({
|
||||
queryKey,
|
||||
enabled: input.enabled && Boolean(input.client && input.cwd && input.path) && subscriptionReady,
|
||||
queryFn: async (): Promise<FileReadResult> => {
|
||||
if (!input.client || !input.cwd || !input.path) throw new Error("File unavailable.");
|
||||
return input.client.readFile(input.cwd, input.path);
|
||||
},
|
||||
dataShape: "value",
|
||||
staleTimeMs: 5_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const observed = latestVersion.current;
|
||||
if (
|
||||
query.data &&
|
||||
observed?.status === "ready" &&
|
||||
query.data.modifiedAt !== observed.modifiedAt
|
||||
) {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
}, [query.data, queryClient, queryKey]);
|
||||
|
||||
return { query, version };
|
||||
}
|
||||
@@ -2,11 +2,7 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
checkoutCommitsQueryKey,
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
} from "@/git/query-keys";
|
||||
import { checkoutPrStatusQueryKey, checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import {
|
||||
prPanePipelineQueryKey,
|
||||
prPaneTimelineQueryKey,
|
||||
@@ -136,25 +132,6 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
|
||||
});
|
||||
|
||||
it("invalidates recent commits when checkout status is pushed", () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] });
|
||||
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/repo2"), { commits: [] });
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus()),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/repo2"))?.isInvalidated,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => {
|
||||
const queryClient = createQueryClient();
|
||||
const pushedPr = prStatus({ requestId: "pr-1" });
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { CheckoutStatusResponse, CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
import {
|
||||
checkoutCommitsQueryKey,
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
invalidatePrPaneTimelineForCheckout,
|
||||
@@ -50,9 +49,6 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
: undefined;
|
||||
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
|
||||
});
|
||||
expireStaleDiffModeOverrides({
|
||||
serverId,
|
||||
cwd: payload.cwd,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import { CommitRow } from "./commit-row";
|
||||
import { dotStyles } from "./shared";
|
||||
|
||||
interface CommitsSectionProps {
|
||||
serverId: string;
|
||||
@@ -136,6 +137,12 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
|
||||
{commitCount}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.legend}>
|
||||
<View style={dotStyles.dotLocal} />
|
||||
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendLocal")}</Text>
|
||||
<View style={dotStyles.legendDotRemote} />
|
||||
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendRemote")}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{collapsed ? null : (
|
||||
<CommitsSectionContent query={query} now={displayNow} onCommitPress={onCommitPress} />
|
||||
@@ -170,6 +177,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
count: {
|
||||
@@ -180,6 +188,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
countSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
legend: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
list: {
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
|
||||
@@ -13,8 +13,11 @@ export const DOT_SIZE = 8;
|
||||
const REMOTE_DOT_OPACITY = 0.55;
|
||||
|
||||
/**
|
||||
* A local-only commit is a hollow ring; a commit that has reached the remote
|
||||
* is a subtle (dimmed) filled green dot.
|
||||
* Shared local/remote commit dot styles used by both the commit rows and the
|
||||
* section legend so the two never drift.
|
||||
*
|
||||
* Semantics: a local-only commit is a hollow ring; a commit that has reached
|
||||
* the remote is a subtle (dimmed) filled green dot.
|
||||
*/
|
||||
export const dotStyles = StyleSheet.create((theme) => ({
|
||||
dotLocal: {
|
||||
@@ -34,4 +37,15 @@ export const dotStyles = StyleSheet.create((theme) => ({
|
||||
opacity: REMOTE_DOT_OPACITY,
|
||||
flexShrink: 0,
|
||||
},
|
||||
// Legend variant of the remote dot: same fill, with leading spacing so it
|
||||
// separates from the preceding "local" label in the section header legend.
|
||||
legendDotRemote: {
|
||||
width: DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
opacity: REMOTE_DOT_OPACITY,
|
||||
flexShrink: 0,
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -2,7 +2,6 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
checkoutDiffQueryKey,
|
||||
checkoutCommitsQueryKey,
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
invalidateCheckoutGitQueriesForClient,
|
||||
@@ -25,8 +24,6 @@ describe("checkout query keys", () => {
|
||||
files: [],
|
||||
});
|
||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
|
||||
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] });
|
||||
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/tmp/other"), { commits: [] });
|
||||
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
|
||||
items: [],
|
||||
});
|
||||
@@ -65,12 +62,6 @@ describe("checkout query keys", () => {
|
||||
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/tmp/other"))?.isInvalidated,
|
||||
).toBe(false);
|
||||
expect(
|
||||
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
|
||||
?.isInvalidated,
|
||||
@@ -111,8 +102,6 @@ describe("checkout query keys", () => {
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true });
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), { isGit: true });
|
||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
|
||||
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] });
|
||||
queryClient.setQueryData(checkoutCommitsQueryKey(otherServerId, cwd), { commits: [] });
|
||||
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
|
||||
items: [],
|
||||
});
|
||||
@@ -139,12 +128,6 @@ describe("checkout query keys", () => {
|
||||
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(checkoutCommitsQueryKey(otherServerId, cwd))?.isInvalidated,
|
||||
).toBe(false);
|
||||
expect(
|
||||
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
|
||||
?.isInvalidated,
|
||||
|
||||
@@ -62,9 +62,6 @@ export async function invalidateCheckoutGitQueriesForClient(
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: checkoutCommitsQueryKey(identity.serverId, identity.cwd),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
|
||||
}),
|
||||
@@ -84,7 +81,6 @@ export async function invalidateCheckoutGitQueriesForServer(
|
||||
const kinds = [
|
||||
"checkoutStatus",
|
||||
"checkoutPrStatus",
|
||||
"checkoutCommits",
|
||||
prPaneTimelineQueryKind,
|
||||
prPanePipelineQueryKind,
|
||||
];
|
||||
|
||||
@@ -471,7 +471,7 @@ describe("computeSidebarOrderUpdates", () => {
|
||||
expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] });
|
||||
});
|
||||
|
||||
it("appends unseen projects while putting unseen workspaces before the saved order", () => {
|
||||
it("appends unseen projects and workspaces to the persisted orders", () => {
|
||||
const projects = [
|
||||
sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1", "ws-2"] }),
|
||||
sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-3"] }),
|
||||
@@ -485,33 +485,11 @@ describe("computeSidebarOrderUpdates", () => {
|
||||
|
||||
expect(updates.projectOrder).toEqual(["project-a", "project-b"]);
|
||||
expect(updates.workspaceOrders).toEqual([
|
||||
{ projectKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] },
|
||||
{ projectKey: "project-a", order: ["srv:ws-1", "srv:ws-2"] },
|
||||
{ projectKey: "project-b", order: ["srv:ws-3"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the saved workspace order behind multiple newly discovered workspaces", () => {
|
||||
const projects = [
|
||||
sidebarProject({
|
||||
projectKey: "project-a",
|
||||
workspaceKeys: ["newest", "newer", "old-a", "old-b"],
|
||||
}),
|
||||
];
|
||||
|
||||
const updates = computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder: ["project-a"],
|
||||
getWorkspaceOrder: () => ["srv:old-b", "srv:old-a"],
|
||||
});
|
||||
|
||||
expect(updates.workspaceOrders).toEqual([
|
||||
{
|
||||
projectKey: "project-a",
|
||||
order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns no project-order update when persisted order already covers visible keys", () => {
|
||||
const projects = [
|
||||
sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1"] }),
|
||||
|
||||
@@ -477,23 +477,6 @@ export function appendMissingOrderKeys(input: {
|
||||
return [...input.currentOrder, ...missingKeys];
|
||||
}
|
||||
|
||||
export function prependMissingOrderKeys(input: {
|
||||
currentOrder: string[];
|
||||
visibleKeys: string[];
|
||||
}): string[] {
|
||||
if (input.visibleKeys.length === 0) {
|
||||
return input.currentOrder;
|
||||
}
|
||||
|
||||
const existingKeys = new Set(input.currentOrder);
|
||||
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key));
|
||||
if (missingKeys.length === 0) {
|
||||
return input.currentOrder;
|
||||
}
|
||||
|
||||
return [...missingKeys, ...input.currentOrder];
|
||||
}
|
||||
|
||||
export interface SidebarOrderUpdates {
|
||||
projectOrder: string[] | null;
|
||||
workspaceOrders: Array<{ projectKey: string; order: string[] }>;
|
||||
@@ -517,7 +500,7 @@ export function computeSidebarOrderUpdates(input: {
|
||||
const workspaceOrders: Array<{ projectKey: string; order: string[] }> = [];
|
||||
for (const project of input.projects) {
|
||||
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.projectKey);
|
||||
const nextWorkspaceOrder = prependMissingOrderKeys({
|
||||
const nextWorkspaceOrder = appendMissingOrderKeys({
|
||||
currentOrder: persistedWorkspaceOrder,
|
||||
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
||||
});
|
||||
|
||||
@@ -209,7 +209,7 @@ describe("deriveAgentScreenViewState", () => {
|
||||
expect(sync.ui).toBe("silent");
|
||||
});
|
||||
|
||||
it("keeps already-hydrated history visible while a newly visible agent catches up", () => {
|
||||
it("covers already-hydrated history while a newly visible agent catches up", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
@@ -225,7 +225,7 @@ describe("deriveAgentScreenViewState", () => {
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectCatchingUpSync(ready);
|
||||
|
||||
expect(sync.ui).toBe("silent");
|
||||
expect(sync.ui).toBe("overlay");
|
||||
});
|
||||
|
||||
it("keeps hydrated history readable after a visibility catch-up error", () => {
|
||||
|
||||
@@ -150,11 +150,13 @@ function resolveCatchingUpUi(args: {
|
||||
hasOptimisticCreateContinuity: boolean;
|
||||
isVisibilityCatchUpPending: boolean;
|
||||
hasHydratedHistoryBefore: boolean;
|
||||
needsAuthoritativeSync: boolean;
|
||||
hadInitialSyncFailure: boolean;
|
||||
}): "overlay" | "silent" {
|
||||
if (args.hasOptimisticCreateContinuity) return "silent";
|
||||
if (args.hasHydratedHistoryBefore) return "silent";
|
||||
if (args.hasHydratedHistoryBefore && args.needsAuthoritativeSync) return "silent";
|
||||
if (args.isVisibilityCatchUpPending) return "overlay";
|
||||
if (args.hasHydratedHistoryBefore) return "silent";
|
||||
if (args.hadInitialSyncFailure) return "silent";
|
||||
return "overlay";
|
||||
}
|
||||
@@ -184,6 +186,7 @@ function resolveAgentScreenSync(args: {
|
||||
hasOptimisticCreateContinuity: hasOptimisticCreateContinuity(input),
|
||||
isVisibilityCatchUpPending: input.visibilityCatchUpStatus === "pending",
|
||||
hasHydratedHistoryBefore: input.hasHydratedHistoryBefore,
|
||||
needsAuthoritativeSync: input.needsAuthoritativeSync,
|
||||
hadInitialSyncFailure,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface UseDictationOptions {
|
||||
|
||||
export interface UseDictationResult {
|
||||
isRecording: boolean;
|
||||
isRecordingActive: () => boolean;
|
||||
isProcessing: boolean;
|
||||
partialTranscript: string;
|
||||
volume: number;
|
||||
|
||||
@@ -59,7 +59,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
useEffect(() => {
|
||||
isRecordingRef.current = isRecording;
|
||||
}, [isRecording]);
|
||||
const isRecordingActive = useCallback(() => isRecordingRef.current, []);
|
||||
|
||||
const isProcessingRef = useRef(isProcessing);
|
||||
useEffect(() => {
|
||||
@@ -454,7 +453,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isRecordingActive,
|
||||
isProcessing,
|
||||
partialTranscript,
|
||||
volume: audio.volume,
|
||||
|
||||
@@ -191,9 +191,6 @@ export function useSettings<TSelected>(
|
||||
if (updates.toolCallDetailLevel !== undefined) {
|
||||
appUpdates.toolCallDetailLevel = updates.toolCallDetailLevel;
|
||||
}
|
||||
if (updates.vimKeybindings !== undefined) {
|
||||
appUpdates.vimKeybindings = updates.vimKeybindings;
|
||||
}
|
||||
const promises: Promise<void>[] = [];
|
||||
if (Object.keys(appUpdates).length > 0) {
|
||||
promises.push(appSettings.updateSettings(appUpdates));
|
||||
|
||||
@@ -43,7 +43,6 @@ export interface AppSettings {
|
||||
workspaceTitleSource: WorkspaceTitleSource;
|
||||
autoExpandReasoning: boolean;
|
||||
toolCallDetailLevel: ToolCallDetailLevel;
|
||||
vimKeybindings: boolean;
|
||||
}
|
||||
|
||||
export interface Settings extends AppSettings {
|
||||
@@ -67,7 +66,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
workspaceTitleSource: "title",
|
||||
autoExpandReasoning: false,
|
||||
toolCallDetailLevel: "detailed",
|
||||
vimKeybindings: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
@@ -235,9 +233,6 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
|
||||
result.syntaxTheme = stored.syntaxTheme;
|
||||
}
|
||||
if (typeof stored.vimKeybindings === "boolean") {
|
||||
result.vimKeybindings = stored.vimKeybindings;
|
||||
}
|
||||
if (
|
||||
typeof stored.workspaceTitleSource === "string" &&
|
||||
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
|
||||
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
||||
@@ -88,9 +89,13 @@ export function useSidebarWorkspacesList(options?: {
|
||||
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
|
||||
|
||||
const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds);
|
||||
const hydratedServerIds = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => serverIds.filter((id) => state.sessions[id]?.hasHydratedWorkspaces ?? false),
|
||||
shallow,
|
||||
);
|
||||
|
||||
const hostProjects = useHostProjects(hydratedServerIds);
|
||||
const hostProjects = useHostProjects(serverIds);
|
||||
|
||||
const sidebarModel = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -466,7 +466,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
tabs: {
|
||||
loading: "تحميل...",
|
||||
modified: "تغييرات غير محفوظة",
|
||||
loadingAgentTitle: "جارٍ تحميل عنوان الوكيل",
|
||||
emptyPane: "لا توجد علامات تبويب في هذا الجزء.",
|
||||
fallback: {
|
||||
@@ -533,13 +532,6 @@ export const ar: TranslationResources = {
|
||||
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "تغييرات غير محفوظة",
|
||||
unsavedMessage:
|
||||
"تحتوي علامة التبويب هذه على تغييرات غير محفوظة. سيؤدي إغلاقها إلى تجاهل المسودة.",
|
||||
closeWithoutSaving: "إغلاق بدون حفظ",
|
||||
closePaneTitle: "إغلاق اللوحة؟",
|
||||
bulkUnsaved:
|
||||
"تحتوي {{count}} علامة تبويب على تغييرات غير محفوظة. سيؤدي الإغلاق إلى تجاهل المسودات.",
|
||||
close: "يغلق",
|
||||
cancel: "يلغي",
|
||||
archive: "أرشيف",
|
||||
@@ -588,34 +580,19 @@ export const ar: TranslationResources = {
|
||||
scripts: {
|
||||
title: "البرامج النصية",
|
||||
actions: {
|
||||
chooseUrl: "اختيار الرابط",
|
||||
copyUrl: "نسخ الرابط",
|
||||
openService: "عرض الخدمة",
|
||||
restart: "إعادة التشغيل",
|
||||
run: "يجري",
|
||||
stop: "إيقاف",
|
||||
view: "عرض الوحدة الطرفية",
|
||||
view: "منظر",
|
||||
},
|
||||
accessibility: {
|
||||
trigger: "البرامج النصية Workspace",
|
||||
openService: "عرض خدمة {{scriptName}}",
|
||||
openAt: "افتح{{scriptName}}في{{label}}",
|
||||
viewTerminal: "عرض محطة{{scriptName}}",
|
||||
runScript: "قم بتشغيل البرنامج النصي{{scriptName}}",
|
||||
stopScript: "إيقاف{{scriptName}}",
|
||||
restartScript: "إعادة تشغيل{{scriptName}}",
|
||||
copyUrl: "نسخ عنوان URL لـ{{scriptName}}",
|
||||
chooseUrl: "اختيار رابط {{scriptName}}",
|
||||
script: "البرنامج النصي{{scriptName}}",
|
||||
},
|
||||
routes: {
|
||||
public: "الوكيل العكسي",
|
||||
paseo: "Memorable",
|
||||
direct: "مباشر",
|
||||
},
|
||||
states: {
|
||||
exitCode: "الخروج من{{code}}",
|
||||
startFailed: "فشل بدء تشغيل{{scriptName}}",
|
||||
stopFailed: "فشل إيقاف{{scriptName}}",
|
||||
},
|
||||
},
|
||||
git: {
|
||||
@@ -788,12 +765,14 @@ export const ar: TranslationResources = {
|
||||
deletedFile: "تم الحذف",
|
||||
commits: {
|
||||
title: "الإيداعات",
|
||||
countLabel: "{{count}} من الإيداعات الأخيرة",
|
||||
legendLocal: "محلي",
|
||||
legendRemote: "على المستودع البعيد",
|
||||
countLabel: "{{count}} إيداعات قبل الأساس",
|
||||
fileDiffEmpty: "لا توجد تغييرات لعرضها",
|
||||
fileDiffError: "تعذّر تحميل فروق الملف",
|
||||
loading: "جارٍ تحميل الإيداعات…",
|
||||
loadError: "تعذّر تحميل الإيداعات",
|
||||
empty: "لا توجد إيداعات بعد",
|
||||
empty: "لا توجد إيداعات قبل الأساس",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
@@ -926,8 +905,6 @@ export const ar: TranslationResources = {
|
||||
actions: {
|
||||
menu: "إجراءات Workspace",
|
||||
newWorkspace: "مساحة عمل جديدة",
|
||||
showMore: "عرض المزيد",
|
||||
showLess: "عرض أقل",
|
||||
createWorkspaceFor: "قم بإنشاء مساحة عمل جديدة لـ{{projectName}}",
|
||||
copyPath: "نسخ المسار",
|
||||
copyBranchName: "انسخ اسم الفرع",
|
||||
@@ -985,6 +962,9 @@ export const ar: TranslationResources = {
|
||||
refPicker: {
|
||||
startingRef: "بدء المرجع",
|
||||
chooseStart: "اختر من أين تبدأ",
|
||||
checkoutHint: "تحقق من {{noun}} {{numberPrefix}}{{number}}؟",
|
||||
checkoutPr: "تحقق من {{noun}} {{numberPrefix}}{{number}}",
|
||||
dismissCheckoutHint: "تجاهل تلميح الخروج {{noun}} {{numberPrefix}}{{number}}",
|
||||
intoBase: "إلى {{baseRef}}",
|
||||
searching: "جارٍ البحث...",
|
||||
noMatchingRefs: "لا توجد مراجع مطابقة.",
|
||||
@@ -1137,6 +1117,20 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "جارٍ التحقق من توفّر الاستيراد…",
|
||||
"host-not-running": "شغّل المضيف المُدار بواسطة تطبيق سطح المكتب قبل الاستيراد.",
|
||||
},
|
||||
actions: { import: "استيراد", importing: "جارٍ الاستيراد…", done: "تم" },
|
||||
unavailable: "الاستيراد عبر تطبيق سطح المكتب غير متاح.",
|
||||
complete: "اكتمل الاستيراد.",
|
||||
failed: "فشل الاستيراد.",
|
||||
description: "استيراد المشاريع والإعدادات وأشجار العمل من جهاز Mac هذا.",
|
||||
sheetTitle: "الاستيراد من {{source}}",
|
||||
confirmation:
|
||||
"سيسجّل Paseo المستودعات الصالحة ويدمج إعدادات المشروع المدعومة ويتبنّى أشجار العمل الجاهزة أو يعيد إنشاءها. لن تتغير بيانات {{source}}.",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.",
|
||||
installFailed: "غير قادر على تثبيت PaseoCLI.",
|
||||
@@ -1465,25 +1459,6 @@ export const ar: TranslationResources = {
|
||||
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
||||
failedToLoad: "فشل تحميل الملف",
|
||||
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
||||
editor: {
|
||||
fileSize: "حجم الملف {{size}}",
|
||||
lines: "{{count}} سطر",
|
||||
editorStatus: "حالة المحرر {{status}}",
|
||||
unsavedChanges: "تغييرات غير محفوظة",
|
||||
saving: "جارٍ الحفظ...",
|
||||
saveFailed: "فشل الحفظ",
|
||||
changedOnDisk: "تغيّر على القرص",
|
||||
vimMode: "وضع Vim {{mode}}",
|
||||
cursor: "السطر {{line}}، العمود {{column}}",
|
||||
preview: "معاينة",
|
||||
source: "المصدر",
|
||||
unavailableTitle: "الملف غير متاح على القرص",
|
||||
conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.",
|
||||
overwrite: "استبدال",
|
||||
reload: "إعادة تحميل",
|
||||
reloadTitle: "إعادة التحميل من القرص؟",
|
||||
reloadMessage: "ستفقد تغييراتك المحلية.",
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "التغييرات",
|
||||
@@ -1579,18 +1554,12 @@ export const ar: TranslationResources = {
|
||||
sections: {
|
||||
general: "عام",
|
||||
appearance: "مظهر",
|
||||
editor: "المحرر",
|
||||
shortcuts: "الاختصارات",
|
||||
integrations: "التكامل",
|
||||
permissions: "الأذونات",
|
||||
diagnostics: "التشخيص",
|
||||
about: "عن",
|
||||
},
|
||||
editor: {
|
||||
title: "المحرر",
|
||||
vimKeybindings: "اختصارات Vim",
|
||||
vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
|
||||
},
|
||||
hostSections: {
|
||||
connections: "اتصالات",
|
||||
agents: "Agents",
|
||||
|
||||
@@ -465,7 +465,6 @@ export const en = {
|
||||
},
|
||||
tabs: {
|
||||
loading: "Loading...",
|
||||
modified: "Unsaved changes",
|
||||
loadingAgentTitle: "Loading agent title",
|
||||
emptyPane: "No tabs in this pane.",
|
||||
fallback: {
|
||||
@@ -535,12 +534,6 @@ export const en = {
|
||||
close: "Close",
|
||||
cancel: "Cancel",
|
||||
archive: "Archive",
|
||||
unsavedTitle: "Unsaved changes",
|
||||
unsavedMessage:
|
||||
"This tab has changes that have not been saved. Closing it will discard the draft.",
|
||||
closeWithoutSaving: "Close without saving",
|
||||
closePaneTitle: "Close pane?",
|
||||
bulkUnsaved: "{{count}} tab(s) have unsaved changes. Closing will discard those drafts.",
|
||||
closeTerminalTitle: "Close terminal?",
|
||||
closeTerminalMessage: "Any running process in this terminal will be stopped immediately.",
|
||||
archiveRunningAgentTitle: "Archive running agent?",
|
||||
@@ -585,34 +578,19 @@ export const en = {
|
||||
scripts: {
|
||||
title: "Scripts",
|
||||
actions: {
|
||||
chooseUrl: "Choose URL",
|
||||
copyUrl: "Copy URL",
|
||||
openService: "View service",
|
||||
restart: "Restart",
|
||||
run: "Run",
|
||||
stop: "Stop",
|
||||
view: "View terminal",
|
||||
view: "View",
|
||||
},
|
||||
accessibility: {
|
||||
trigger: "Workspace scripts",
|
||||
openService: "View {{scriptName}} service",
|
||||
openAt: "Open {{scriptName}} at {{label}}",
|
||||
viewTerminal: "View {{scriptName}} terminal",
|
||||
runScript: "Run {{scriptName}} script",
|
||||
stopScript: "Stop {{scriptName}}",
|
||||
restartScript: "Restart {{scriptName}}",
|
||||
copyUrl: "Copy {{scriptName}} URL",
|
||||
chooseUrl: "Choose URL for {{scriptName}}",
|
||||
script: "{{scriptName}} script",
|
||||
},
|
||||
routes: {
|
||||
public: "Reverse proxy",
|
||||
paseo: "Memorable",
|
||||
direct: "Direct",
|
||||
},
|
||||
states: {
|
||||
exitCode: "exit {{code}}",
|
||||
startFailed: "Failed to start {{scriptName}}",
|
||||
stopFailed: "Failed to stop {{scriptName}}",
|
||||
},
|
||||
},
|
||||
git: {
|
||||
@@ -798,12 +776,14 @@ export const en = {
|
||||
deletedFile: "Deleted",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
countLabel: "{{count}} recent commits",
|
||||
legendLocal: "local",
|
||||
legendRemote: "on remote",
|
||||
countLabel: "{{count}} commits ahead of base",
|
||||
fileDiffEmpty: "No changes to display",
|
||||
fileDiffError: "Failed to load file diff",
|
||||
loading: "Loading commits…",
|
||||
loadError: "Failed to load commits",
|
||||
empty: "No commits yet",
|
||||
empty: "No commits ahead of base",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
@@ -936,8 +916,6 @@ export const en = {
|
||||
actions: {
|
||||
menu: "Workspace actions",
|
||||
newWorkspace: "New workspace",
|
||||
showMore: "Show more",
|
||||
showLess: "Show less",
|
||||
createWorkspaceFor: "Create a new workspace for {{projectName}}",
|
||||
copyPath: "Copy path",
|
||||
copyBranchName: "Copy branch name",
|
||||
@@ -995,6 +973,9 @@ export const en = {
|
||||
refPicker: {
|
||||
startingRef: "Starting ref",
|
||||
chooseStart: "Choose where to start from",
|
||||
checkoutHint: "Check out {{noun}} {{numberPrefix}}{{number}}?",
|
||||
checkoutPr: "Check out {{noun}} {{numberPrefix}}{{number}}",
|
||||
dismissCheckoutHint: "Dismiss {{noun}} {{numberPrefix}}{{number}} checkout hint",
|
||||
intoBase: "into {{baseRef}}",
|
||||
searching: "Searching...",
|
||||
noMatchingRefs: "No matching refs.",
|
||||
@@ -1147,6 +1128,20 @@ export const en = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Checking import availability…",
|
||||
"host-not-running": "Start the Desktop-managed host before importing.",
|
||||
},
|
||||
actions: { import: "Import", importing: "Importing…", done: "Done" },
|
||||
unavailable: "Desktop import is unavailable.",
|
||||
complete: "Import complete.",
|
||||
failed: "Import failed.",
|
||||
description: "Import projects, settings, and worktrees from this Mac.",
|
||||
sheetTitle: "Import from {{source}}",
|
||||
confirmation:
|
||||
"Paseo will register valid repositories, merge supported project settings, and adopt or recreate ready worktrees. {{source}} data will not be changed.",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "Unable to check CLI install status.",
|
||||
installFailed: "Unable to install the Paseo CLI.",
|
||||
@@ -1476,25 +1471,6 @@ export const en = {
|
||||
binaryPreviewUnavailable: "Binary preview unavailable",
|
||||
failedToLoad: "Failed to load file",
|
||||
failedToLoadPreview: "Failed to load file preview",
|
||||
editor: {
|
||||
fileSize: "File size {{size}}",
|
||||
lines: "{{count}} lines",
|
||||
editorStatus: "Editor status {{status}}",
|
||||
unsavedChanges: "Unsaved changes",
|
||||
saving: "Saving...",
|
||||
saveFailed: "Save failed",
|
||||
changedOnDisk: "Changed on disk",
|
||||
vimMode: "Vim mode {{mode}}",
|
||||
cursor: "Line {{line}}, column {{column}}",
|
||||
preview: "Preview",
|
||||
source: "Source",
|
||||
unavailableTitle: "File unavailable on disk",
|
||||
conflictDescription: "The local buffer was preserved. Choose which version to keep.",
|
||||
overwrite: "Overwrite",
|
||||
reload: "Reload",
|
||||
reloadTitle: "Reload from disk?",
|
||||
reloadMessage: "Your local changes will be lost.",
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Changes",
|
||||
@@ -1590,18 +1566,12 @@ export const en = {
|
||||
sections: {
|
||||
general: "General",
|
||||
appearance: "Appearance",
|
||||
editor: "Editor",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
diagnostics: "Diagnostics",
|
||||
about: "About",
|
||||
},
|
||||
editor: {
|
||||
title: "Editor",
|
||||
vimKeybindings: "Vim keybindings",
|
||||
vimHint: "Applies to source files on web and desktop.",
|
||||
},
|
||||
hostSections: {
|
||||
connections: "Connections",
|
||||
agents: "Agents",
|
||||
|
||||
@@ -470,7 +470,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
tabs: {
|
||||
loading: "Cargando...",
|
||||
modified: "Cambios sin guardar",
|
||||
loadingAgentTitle: "Título del agente de carga",
|
||||
emptyPane: "No hay pestañas en este panel.",
|
||||
fallback: {
|
||||
@@ -538,13 +537,6 @@ export const es: TranslationResources = {
|
||||
failedToReloadAgent: "No se pudo recargar el agente",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "Cambios sin guardar",
|
||||
unsavedMessage:
|
||||
"Esta pestaña tiene cambios sin guardar. Al cerrarla se descartará el borrador.",
|
||||
closeWithoutSaving: "Cerrar sin guardar",
|
||||
closePaneTitle: "¿Cerrar panel?",
|
||||
bulkUnsaved:
|
||||
"{{count}} pestaña(s) tienen cambios sin guardar. Al cerrar se descartarán esos borradores.",
|
||||
close: "Cerca",
|
||||
cancel: "Cancelar",
|
||||
archive: "Archivo",
|
||||
@@ -594,34 +586,19 @@ export const es: TranslationResources = {
|
||||
scripts: {
|
||||
title: "Scripts",
|
||||
actions: {
|
||||
chooseUrl: "Elegir URL",
|
||||
copyUrl: "Copiar URL",
|
||||
openService: "Ver servicio",
|
||||
restart: "Reiniciar",
|
||||
run: "Correr",
|
||||
stop: "Detener",
|
||||
view: "Ver terminal",
|
||||
view: "Vista",
|
||||
},
|
||||
accessibility: {
|
||||
trigger: "GuionesWorkspace",
|
||||
openService: "Ver servicio {{scriptName}}",
|
||||
openAt: "Abrir{{scriptName}}en{{label}}",
|
||||
viewTerminal: "Ver terminal{{scriptName}}",
|
||||
runScript: "Ejecute el script{{scriptName}}",
|
||||
stopScript: "Detener{{scriptName}}",
|
||||
restartScript: "Reiniciar{{scriptName}}",
|
||||
copyUrl: "Copiar URL de{{scriptName}}",
|
||||
chooseUrl: "Elegir URL para {{scriptName}}",
|
||||
script: "Guión{{scriptName}}",
|
||||
},
|
||||
routes: {
|
||||
public: "Proxy inverso",
|
||||
paseo: "Memorable",
|
||||
direct: "Directa",
|
||||
},
|
||||
states: {
|
||||
exitCode: "salir de{{code}}",
|
||||
startFailed: "No se pudo iniciar{{scriptName}}",
|
||||
stopFailed: "No se pudo detener{{scriptName}}",
|
||||
},
|
||||
},
|
||||
git: {
|
||||
@@ -819,12 +796,14 @@ export const es: TranslationResources = {
|
||||
deletedFile: "Eliminado",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
countLabel: "{{count}} commits recientes",
|
||||
legendLocal: "local",
|
||||
legendRemote: "en remoto",
|
||||
countLabel: "{{count}} commits por delante de la base",
|
||||
fileDiffEmpty: "No hay cambios para mostrar",
|
||||
fileDiffError: "Error al cargar el diff del archivo",
|
||||
loading: "Cargando commits…",
|
||||
loadError: "Error al cargar los commits",
|
||||
empty: "Aún no hay commits",
|
||||
empty: "No hay commits por delante de la base",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
@@ -957,8 +936,6 @@ export const es: TranslationResources = {
|
||||
actions: {
|
||||
menu: "AccionesWorkspace",
|
||||
newWorkspace: "Nuevo espacio de trabajo",
|
||||
showMore: "Mostrar más",
|
||||
showLess: "Mostrar menos",
|
||||
createWorkspaceFor: "Crea un nuevo espacio de trabajo para{{projectName}}",
|
||||
copyPath: "Copiar ruta",
|
||||
copyBranchName: "Copiar nombre de sucursal",
|
||||
@@ -1016,6 +993,9 @@ export const es: TranslationResources = {
|
||||
refPicker: {
|
||||
startingRef: "Árbitro inicial",
|
||||
chooseStart: "Elige por dónde empezar",
|
||||
checkoutHint: "¿Mira {{noun}} {{numberPrefix}}{{number}}?",
|
||||
checkoutPr: "Echa un vistazo a {{noun}} {{numberPrefix}}{{number}}",
|
||||
dismissCheckoutHint: "Descartar la sugerencia de pago de {{noun}} {{numberPrefix}}{{number}}",
|
||||
intoBase: "en {{baseRef}}",
|
||||
searching: "Búsqueda...",
|
||||
noMatchingRefs: "No hay árbitros coincidentes.",
|
||||
@@ -1177,6 +1157,20 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Comprobando la disponibilidad de la importación…",
|
||||
"host-not-running": "Inicia el host gestionado por Desktop antes de importar.",
|
||||
},
|
||||
actions: { import: "Importar", importing: "Importando…", done: "Listo" },
|
||||
unavailable: "La importación de escritorio no está disponible.",
|
||||
complete: "Importación completada.",
|
||||
failed: "La importación ha fallado.",
|
||||
description: "Importa proyectos, ajustes y worktrees desde este Mac.",
|
||||
sheetTitle: "Importar desde {{source}}",
|
||||
confirmation:
|
||||
"Paseo registrará los repositorios válidos, combinará los ajustes de proyecto compatibles y adoptará o recreará los worktrees preparados. Los datos de {{source}} no se modificarán.",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "No se puede verificar el estado de instalación deCLI.",
|
||||
installFailed: "No se puede instalar elPaseoCLI.",
|
||||
@@ -1508,25 +1502,6 @@ export const es: TranslationResources = {
|
||||
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
||||
failedToLoad: "No se pudo cargar el archivo",
|
||||
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
||||
editor: {
|
||||
fileSize: "Tamaño {{size}}",
|
||||
lines: "{{count}} líneas",
|
||||
editorStatus: "Estado del editor: {{status}}",
|
||||
unsavedChanges: "Cambios sin guardar",
|
||||
saving: "Guardando...",
|
||||
saveFailed: "Error al guardar",
|
||||
changedOnDisk: "Cambiado en disco",
|
||||
vimMode: "Modo Vim {{mode}}",
|
||||
cursor: "Línea {{line}}, columna {{column}}",
|
||||
preview: "Vista previa",
|
||||
source: "Código fuente",
|
||||
unavailableTitle: "Archivo no disponible en disco",
|
||||
conflictDescription: "Se conservó el búfer local. Elige qué versión mantener.",
|
||||
overwrite: "Sobrescribir",
|
||||
reload: "Recargar",
|
||||
reloadTitle: "¿Recargar desde el disco?",
|
||||
reloadMessage: "Se perderán tus cambios locales.",
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Cambios",
|
||||
@@ -1622,18 +1597,12 @@ export const es: TranslationResources = {
|
||||
sections: {
|
||||
general: "General",
|
||||
appearance: "Apariencia",
|
||||
editor: "Editor",
|
||||
shortcuts: "Atajos",
|
||||
integrations: "Integraciones",
|
||||
permissions: "Permisos",
|
||||
diagnostics: "Diagnóstico",
|
||||
about: "Acerca de",
|
||||
},
|
||||
editor: {
|
||||
title: "Editor",
|
||||
vimKeybindings: "Atajos de Vim",
|
||||
vimHint: "Se aplica a archivos fuente en web y escritorio.",
|
||||
},
|
||||
hostSections: {
|
||||
connections: "Conexiones",
|
||||
agents: "Agents",
|
||||
|
||||
@@ -470,7 +470,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
tabs: {
|
||||
loading: "Chargement...",
|
||||
modified: "Modifications non enregistrées",
|
||||
loadingAgentTitle: "Titre d'agent de chargement",
|
||||
emptyPane: "Aucun onglet dans ce volet.",
|
||||
fallback: {
|
||||
@@ -538,13 +537,6 @@ export const fr: TranslationResources = {
|
||||
failedToReloadAgent: "Échec du rechargement de l'agent",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "Modifications non enregistrées",
|
||||
unsavedMessage:
|
||||
"Cet onglet contient des modifications non enregistrées. Le fermer supprimera le brouillon.",
|
||||
closeWithoutSaving: "Fermer sans enregistrer",
|
||||
closePaneTitle: "Fermer le volet?",
|
||||
bulkUnsaved:
|
||||
"{{count}} onglet(s) contiennent des modifications non enregistrées. Les fermer supprimera ces brouillons.",
|
||||
close: "Fermer",
|
||||
cancel: "Annuler",
|
||||
archive: "Archive",
|
||||
@@ -594,34 +586,19 @@ export const fr: TranslationResources = {
|
||||
scripts: {
|
||||
title: "Scripts",
|
||||
actions: {
|
||||
chooseUrl: "Choisir l’URL",
|
||||
copyUrl: "Copier l’URL",
|
||||
openService: "Voir le service",
|
||||
restart: "Redémarrer",
|
||||
run: "Courir",
|
||||
stop: "Arrêter",
|
||||
view: "Voir le terminal",
|
||||
view: "Voir",
|
||||
},
|
||||
accessibility: {
|
||||
trigger: "ScriptsWorkspace",
|
||||
openService: "Voir le service {{scriptName}}",
|
||||
openAt: "Ouvrir{{scriptName}}à{{label}}",
|
||||
viewTerminal: "Voir le terminal{{scriptName}}",
|
||||
runScript: "Exécuter le script{{scriptName}}",
|
||||
stopScript: "Arrêter{{scriptName}}",
|
||||
restartScript: "Redémarrer{{scriptName}}",
|
||||
copyUrl: "Copier l'URL de{{scriptName}}",
|
||||
chooseUrl: "Choisir l’URL pour {{scriptName}}",
|
||||
script: "Script{{scriptName}}",
|
||||
},
|
||||
routes: {
|
||||
public: "Proxy inverse",
|
||||
paseo: "Memorable",
|
||||
direct: "Directe",
|
||||
},
|
||||
states: {
|
||||
exitCode: "quitter{{code}}",
|
||||
startFailed: "Échec du démarrage de{{scriptName}}",
|
||||
stopFailed: "Échec de l'arrêt de{{scriptName}}",
|
||||
},
|
||||
},
|
||||
git: {
|
||||
@@ -818,12 +795,14 @@ export const fr: TranslationResources = {
|
||||
deletedFile: "Supprimé",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
countLabel: "{{count}} commits récents",
|
||||
legendLocal: "local",
|
||||
legendRemote: "sur le distant",
|
||||
countLabel: "{{count}} commits en avance sur la base",
|
||||
fileDiffEmpty: "Aucune modification à afficher",
|
||||
fileDiffError: "Échec du chargement du diff du fichier",
|
||||
loading: "Chargement des commits…",
|
||||
loadError: "Échec du chargement des commits",
|
||||
empty: "Aucun commit pour le moment",
|
||||
empty: "Aucun commit en avance sur la base",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
@@ -956,8 +935,6 @@ export const fr: TranslationResources = {
|
||||
actions: {
|
||||
menu: "ActionsWorkspace",
|
||||
newWorkspace: "Nouvel espace de travail",
|
||||
showMore: "Afficher plus",
|
||||
showLess: "Afficher moins",
|
||||
createWorkspaceFor: "Créer un nouvel espace de travail pour{{projectName}}",
|
||||
copyPath: "Copier le chemin",
|
||||
copyBranchName: "Copier le nom de la branche",
|
||||
@@ -1015,6 +992,9 @@ export const fr: TranslationResources = {
|
||||
refPicker: {
|
||||
startingRef: "Réf de départ",
|
||||
chooseStart: "Choisissez par où commencer",
|
||||
checkoutHint: "Découvrez {{noun}} {{numberPrefix}}{{number}} ?",
|
||||
checkoutPr: "Découvrez {{noun}} {{numberPrefix}}{{number}}",
|
||||
dismissCheckoutHint: "Ignorer l'indice de paiement {{noun}} {{numberPrefix}}{{number}}",
|
||||
intoBase: "dans {{baseRef}}",
|
||||
searching: "Recherche...",
|
||||
noMatchingRefs: "Aucune référence correspondante.",
|
||||
@@ -1179,6 +1159,20 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Vérification de la disponibilité de l’importation…",
|
||||
"host-not-running": "Démarrez l’hôte géré par Desktop avant l’importation.",
|
||||
},
|
||||
actions: { import: "Importer", importing: "Importation…", done: "Terminé" },
|
||||
unavailable: "L’importation depuis l’application de bureau est indisponible.",
|
||||
complete: "Importation terminée.",
|
||||
failed: "Échec de l’importation.",
|
||||
description: "Importez les projets, réglages et worktrees depuis ce Mac.",
|
||||
sheetTitle: "Importer depuis {{source}}",
|
||||
confirmation:
|
||||
"Paseo enregistrera les dépôts valides, fusionnera les réglages de projet compatibles et adoptera ou recréera les worktrees prêts. Les données de {{source}} ne seront pas modifiées.",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "Impossible de vérifier l'état de l'installation deCLI.",
|
||||
installFailed: "Impossible d'installer lePaseoCLI.",
|
||||
@@ -1511,25 +1505,6 @@ export const fr: TranslationResources = {
|
||||
binaryPreviewUnavailable: "Aperçu binaire indisponible",
|
||||
failedToLoad: "Échec du chargement du fichier",
|
||||
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
|
||||
editor: {
|
||||
fileSize: "Taille {{size}}",
|
||||
lines: "{{count}} lignes",
|
||||
editorStatus: "État de l’éditeur : {{status}}",
|
||||
unsavedChanges: "Modifications non enregistrées",
|
||||
saving: "Enregistrement...",
|
||||
saveFailed: "Échec de l’enregistrement",
|
||||
changedOnDisk: "Modifié sur le disque",
|
||||
vimMode: "Mode Vim {{mode}}",
|
||||
cursor: "Ligne {{line}}, colonne {{column}}",
|
||||
preview: "Aperçu",
|
||||
source: "Source",
|
||||
unavailableTitle: "Fichier indisponible sur le disque",
|
||||
conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.",
|
||||
overwrite: "Écraser",
|
||||
reload: "Recharger",
|
||||
reloadTitle: "Recharger depuis le disque ?",
|
||||
reloadMessage: "Vos modifications locales seront perdues.",
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Modifications",
|
||||
@@ -1625,18 +1600,12 @@ export const fr: TranslationResources = {
|
||||
sections: {
|
||||
general: "Général",
|
||||
appearance: "Apparence",
|
||||
editor: "Éditeur",
|
||||
shortcuts: "Raccourcis",
|
||||
integrations: "Intégrations",
|
||||
permissions: "Autorisations",
|
||||
diagnostics: "Diagnostic",
|
||||
about: "À propos",
|
||||
},
|
||||
editor: {
|
||||
title: "Éditeur",
|
||||
vimKeybindings: "Raccourcis Vim",
|
||||
vimHint: "S’applique aux fichiers source sur le web et le bureau.",
|
||||
},
|
||||
hostSections: {
|
||||
connections: "Relations",
|
||||
agents: "Agents",
|
||||
|
||||
@@ -470,7 +470,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
tabs: {
|
||||
loading: "読み込み中...",
|
||||
modified: "未保存の変更",
|
||||
loadingAgentTitle: "エージェントタイトルを読み込み中",
|
||||
emptyPane: "このペインにタブがありません。",
|
||||
fallback: {
|
||||
@@ -538,11 +537,6 @@ export const ja: TranslationResources = {
|
||||
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "未保存の変更",
|
||||
unsavedMessage: "このタブには未保存の変更があります。閉じると下書きが破棄されます。",
|
||||
closeWithoutSaving: "保存せずに閉じる",
|
||||
closePaneTitle: "ペインを閉じますか?",
|
||||
bulkUnsaved: "{{count}} 個のタブに未保存の変更があります。閉じると下書きが破棄されます。",
|
||||
close: "閉じる",
|
||||
cancel: "キャンセル",
|
||||
archive: "アーカイブ",
|
||||
@@ -590,34 +584,19 @@ export const ja: TranslationResources = {
|
||||
scripts: {
|
||||
title: "スクリプト",
|
||||
actions: {
|
||||
chooseUrl: "URLを選択",
|
||||
copyUrl: "URLをコピー",
|
||||
openService: "サービスを表示",
|
||||
restart: "再起動",
|
||||
run: "実行",
|
||||
stop: "停止",
|
||||
view: "ターミナルを表示",
|
||||
view: "表示",
|
||||
},
|
||||
accessibility: {
|
||||
trigger: "ワークスペーススクリプト",
|
||||
openService: "{{scriptName}}サービスを表示",
|
||||
openAt: "{{label}}で{{scriptName}}を開く",
|
||||
viewTerminal: "{{scriptName}}ターミナルを表示",
|
||||
runScript: "{{scriptName}}スクリプトを実行",
|
||||
stopScript: "{{scriptName}}を停止",
|
||||
restartScript: "{{scriptName}}を再起動",
|
||||
copyUrl: "{{scriptName}}のURLをコピー",
|
||||
chooseUrl: "{{scriptName}}のURLを選択",
|
||||
script: "{{scriptName}}スクリプト",
|
||||
},
|
||||
routes: {
|
||||
public: "リバースプロキシ",
|
||||
paseo: "Memorable",
|
||||
direct: "直接接続",
|
||||
},
|
||||
states: {
|
||||
exitCode: "終了コード: {{code}}",
|
||||
startFailed: "{{scriptName}}の起動に失敗しました",
|
||||
stopFailed: "{{scriptName}}の停止に失敗しました",
|
||||
},
|
||||
},
|
||||
git: {
|
||||
@@ -799,12 +778,14 @@ export const ja: TranslationResources = {
|
||||
deletedFile: "削除済み",
|
||||
commits: {
|
||||
title: "コミット",
|
||||
countLabel: "最近のコミット数: {{count}}",
|
||||
legendLocal: "ローカル",
|
||||
legendRemote: "リモート",
|
||||
countLabel: "ベースより先のコミット数: {{count}}",
|
||||
fileDiffEmpty: "表示する変更はありません",
|
||||
fileDiffError: "ファイル差分の読み込みに失敗しました",
|
||||
loading: "コミットを読み込み中…",
|
||||
loadError: "コミットの読み込みに失敗しました",
|
||||
empty: "コミットはまだありません",
|
||||
empty: "ベースより先のコミットはありません",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
@@ -937,8 +918,6 @@ export const ja: TranslationResources = {
|
||||
actions: {
|
||||
menu: "ワークスペースアクション",
|
||||
newWorkspace: "新しいワークスペース",
|
||||
showMore: "さらに表示",
|
||||
showLess: "表示を減らす",
|
||||
createWorkspaceFor: "{{projectName}}の新しいワークスペースを作成",
|
||||
copyPath: "パスをコピー",
|
||||
copyBranchName: "ブランチ名をコピー",
|
||||
@@ -996,6 +975,9 @@ export const ja: TranslationResources = {
|
||||
refPicker: {
|
||||
startingRef: "開始Ref",
|
||||
chooseStart: "開始点を選択",
|
||||
checkoutHint: "{{noun}} {{numberPrefix}}{{number}}をチェックアウトしますか?",
|
||||
checkoutPr: "{{noun}} {{numberPrefix}}{{number}}をチェックアウト",
|
||||
dismissCheckoutHint: "{{noun}} {{numberPrefix}}{{number}}のチェックアウトヒントを閉じる",
|
||||
intoBase: "{{baseRef}}に",
|
||||
searching: "検索中...",
|
||||
noMatchingRefs: "一致するRefがありません。",
|
||||
@@ -1150,6 +1132,20 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "インポートが利用可能か確認しています…",
|
||||
"host-not-running": "インポートする前にDesktop管理ホストを起動してください。",
|
||||
},
|
||||
actions: { import: "インポート", importing: "インポート中…", done: "完了" },
|
||||
unavailable: "デスクトップインポートは利用できません。",
|
||||
complete: "インポートが完了しました。",
|
||||
failed: "インポートに失敗しました。",
|
||||
description: "このMacからプロジェクト、設定、worktreeをインポートします。",
|
||||
sheetTitle: "{{source}}からインポート",
|
||||
confirmation:
|
||||
"Paseoは有効なリポジトリを登録し、対応するプロジェクト設定を統合して、準備済みのworktreeを採用または再作成します。{{source}}のデータは変更されません。",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "CLIのインストール状態を確認できません。",
|
||||
installFailed: "Paseo CLIをインストールできません。",
|
||||
@@ -1481,25 +1477,6 @@ export const ja: TranslationResources = {
|
||||
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
|
||||
failedToLoad: "ファイルの読み込みに失敗しました",
|
||||
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
||||
editor: {
|
||||
fileSize: "ファイルサイズ {{size}}",
|
||||
lines: "{{count}} 行",
|
||||
editorStatus: "エディターの状態 {{status}}",
|
||||
unsavedChanges: "未保存の変更",
|
||||
saving: "保存中...",
|
||||
saveFailed: "保存に失敗しました",
|
||||
changedOnDisk: "ディスク上で変更されました",
|
||||
vimMode: "Vim モード {{mode}}",
|
||||
cursor: "{{line}} 行、{{column}} 列",
|
||||
preview: "プレビュー",
|
||||
source: "ソース",
|
||||
unavailableTitle: "ディスク上のファイルを利用できません",
|
||||
conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。",
|
||||
overwrite: "上書き",
|
||||
reload: "再読み込み",
|
||||
reloadTitle: "ディスクから再読み込みしますか?",
|
||||
reloadMessage: "ローカルの変更は失われます。",
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "変更",
|
||||
@@ -1595,18 +1572,12 @@ export const ja: TranslationResources = {
|
||||
sections: {
|
||||
general: "一般",
|
||||
appearance: "外観",
|
||||
editor: "エディター",
|
||||
shortcuts: "ショートカット",
|
||||
integrations: "連携",
|
||||
permissions: "権限",
|
||||
diagnostics: "診断",
|
||||
about: "アプリ情報",
|
||||
},
|
||||
editor: {
|
||||
title: "エディター",
|
||||
vimKeybindings: "Vim キーバインド",
|
||||
vimHint: "Web とデスクトップのソースファイルに適用されます。",
|
||||
},
|
||||
hostSections: {
|
||||
connections: "接続",
|
||||
agents: "エージェント",
|
||||
|
||||
@@ -470,7 +470,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
tabs: {
|
||||
loading: "Carregando...",
|
||||
modified: "Alterações não salvas",
|
||||
loadingAgentTitle: "Carregando título do agente",
|
||||
emptyPane: "Nenhuma aba neste painel.",
|
||||
fallback: {
|
||||
@@ -537,12 +536,6 @@ export const ptBR: TranslationResources = {
|
||||
failedToReloadAgent: "Falha ao recarregar agente",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "Alterações não salvas",
|
||||
unsavedMessage: "Esta aba tem alterações não salvas. Fechá-la descartará o rascunho.",
|
||||
closeWithoutSaving: "Fechar sem salvar",
|
||||
closePaneTitle: "Fechar painel?",
|
||||
bulkUnsaved:
|
||||
"{{count}} aba(s) têm alterações não salvas. Fechar descartará esses rascunhos.",
|
||||
close: "Fechar",
|
||||
cancel: "Cancelar",
|
||||
archive: "Arquivar",
|
||||
@@ -591,34 +584,19 @@ export const ptBR: TranslationResources = {
|
||||
scripts: {
|
||||
title: "Scripts",
|
||||
actions: {
|
||||
chooseUrl: "Escolher URL",
|
||||
copyUrl: "Copiar URL",
|
||||
openService: "Ver serviço",
|
||||
restart: "Reiniciar",
|
||||
run: "Executar",
|
||||
stop: "Parar",
|
||||
view: "Ver terminal",
|
||||
view: "Ver",
|
||||
},
|
||||
accessibility: {
|
||||
trigger: "Scripts do workspace",
|
||||
openService: "Ver serviço {{scriptName}}",
|
||||
openAt: "Abrir {{scriptName}} em {{label}}",
|
||||
viewTerminal: "Ver terminal de {{scriptName}}",
|
||||
runScript: "Executar script {{scriptName}}",
|
||||
stopScript: "Parar {{scriptName}}",
|
||||
restartScript: "Reiniciar {{scriptName}}",
|
||||
copyUrl: "Copiar URL de {{scriptName}}",
|
||||
chooseUrl: "Escolher URL para {{scriptName}}",
|
||||
script: "script {{scriptName}}",
|
||||
},
|
||||
routes: {
|
||||
public: "Proxy reverso",
|
||||
paseo: "Memorable",
|
||||
direct: "Direta",
|
||||
},
|
||||
states: {
|
||||
exitCode: "saída {{code}}",
|
||||
startFailed: "Falha ao iniciar {{scriptName}}",
|
||||
stopFailed: "Falha ao parar {{scriptName}}",
|
||||
},
|
||||
},
|
||||
git: {
|
||||
@@ -810,12 +788,14 @@ export const ptBR: TranslationResources = {
|
||||
deletedFile: "Excluído",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
countLabel: "{{count}} commits recentes",
|
||||
legendLocal: "local",
|
||||
legendRemote: "no remoto",
|
||||
countLabel: "{{count}} commits à frente da base",
|
||||
fileDiffEmpty: "Nenhuma alteração para exibir",
|
||||
fileDiffError: "Falha ao carregar diff do arquivo",
|
||||
loading: "Carregando commits…",
|
||||
loadError: "Falha ao carregar commits",
|
||||
empty: "Ainda não há commits",
|
||||
empty: "Nenhum commit à frente da base",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
@@ -948,8 +928,6 @@ export const ptBR: TranslationResources = {
|
||||
actions: {
|
||||
menu: "Ações do workspace",
|
||||
newWorkspace: "Novo workspace",
|
||||
showMore: "Mostrar mais",
|
||||
showLess: "Mostrar menos",
|
||||
createWorkspaceFor: "Criar um novo workspace para {{projectName}}",
|
||||
copyPath: "Copiar caminho",
|
||||
copyBranchName: "Copiar nome da branch",
|
||||
@@ -1007,6 +985,9 @@ export const ptBR: TranslationResources = {
|
||||
refPicker: {
|
||||
startingRef: "Ref inicial",
|
||||
chooseStart: "Escolha de onde começar",
|
||||
checkoutHint: "Fazer checkout da {{noun}} {{numberPrefix}}{{number}}?",
|
||||
checkoutPr: "Fazer checkout da {{noun}} {{numberPrefix}}{{number}}",
|
||||
dismissCheckoutHint: "Dispensar dica de checkout da {{noun}} {{numberPrefix}}{{number}}",
|
||||
intoBase: "em {{baseRef}}",
|
||||
searching: "Buscando...",
|
||||
noMatchingRefs: "Nenhuma ref correspondente.",
|
||||
@@ -1163,6 +1144,20 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Verificando a disponibilidade da importação…",
|
||||
"host-not-running": "Inicie o host gerenciado pelo Desktop antes de importar.",
|
||||
},
|
||||
actions: { import: "Importar", importing: "Importando…", done: "Concluído" },
|
||||
unavailable: "A importação pelo aplicativo para desktop não está disponível.",
|
||||
complete: "Importação concluída.",
|
||||
failed: "Falha na importação.",
|
||||
description: "Importe projetos, configurações e worktrees deste Mac.",
|
||||
sheetTitle: "Importar do {{source}}",
|
||||
confirmation:
|
||||
"O Paseo registrará repositórios válidos, mesclará configurações de projeto compatíveis e adotará ou recriará worktrees prontos. Os dados do {{source}} não serão alterados.",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "Não foi possível verificar o status de instalação da CLI.",
|
||||
installFailed: "Não foi possível instalar a CLI do Paseo.",
|
||||
@@ -1494,25 +1489,6 @@ export const ptBR: TranslationResources = {
|
||||
binaryPreviewUnavailable: "Prévia binária indisponível",
|
||||
failedToLoad: "Falha ao carregar arquivo",
|
||||
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
||||
editor: {
|
||||
fileSize: "Tamanho {{size}}",
|
||||
lines: "{{count}} linhas",
|
||||
editorStatus: "Status do editor: {{status}}",
|
||||
unsavedChanges: "Alterações não salvas",
|
||||
saving: "Salvando...",
|
||||
saveFailed: "Falha ao salvar",
|
||||
changedOnDisk: "Alterado no disco",
|
||||
vimMode: "Modo Vim {{mode}}",
|
||||
cursor: "Linha {{line}}, coluna {{column}}",
|
||||
preview: "Prévia",
|
||||
source: "Fonte",
|
||||
unavailableTitle: "Arquivo indisponível no disco",
|
||||
conflictDescription: "O conteúdo local foi preservado. Escolha qual versão manter.",
|
||||
overwrite: "Sobrescrever",
|
||||
reload: "Recarregar",
|
||||
reloadTitle: "Recarregar do disco?",
|
||||
reloadMessage: "Suas alterações locais serão perdidas.",
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Alterações",
|
||||
@@ -1608,18 +1584,12 @@ export const ptBR: TranslationResources = {
|
||||
sections: {
|
||||
general: "Geral",
|
||||
appearance: "Aparência",
|
||||
editor: "Editor",
|
||||
shortcuts: "Atalhos",
|
||||
integrations: "Integrações",
|
||||
permissions: "Permissões",
|
||||
diagnostics: "Diagnósticos",
|
||||
about: "Sobre",
|
||||
},
|
||||
editor: {
|
||||
title: "Editor",
|
||||
vimKeybindings: "Atalhos do Vim",
|
||||
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
|
||||
},
|
||||
hostSections: {
|
||||
connections: "Conexões",
|
||||
agents: "Agentes",
|
||||
|
||||
@@ -470,7 +470,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
tabs: {
|
||||
loading: "Загрузка...",
|
||||
modified: "Несохранённые изменения",
|
||||
loadingAgentTitle: "Название агента загрузки",
|
||||
emptyPane: "На этой панели нет вкладок.",
|
||||
fallback: {
|
||||
@@ -537,13 +536,6 @@ export const ru: TranslationResources = {
|
||||
failedToReloadAgent: "Не удалось перезагрузить агент",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "Несохранённые изменения",
|
||||
unsavedMessage:
|
||||
"В этой вкладке есть несохранённые изменения. При закрытии черновик будет удалён.",
|
||||
closeWithoutSaving: "Закрыть без сохранения",
|
||||
closePaneTitle: "Закрыть панель?",
|
||||
bulkUnsaved:
|
||||
"В {{count}} вкладках есть несохранённые изменения. При закрытии черновики будут удалены.",
|
||||
close: "Закрывать",
|
||||
cancel: "Отмена",
|
||||
archive: "Архив",
|
||||
@@ -593,34 +585,19 @@ export const ru: TranslationResources = {
|
||||
scripts: {
|
||||
title: "Скрипты",
|
||||
actions: {
|
||||
chooseUrl: "Выбрать URL",
|
||||
copyUrl: "Скопировать URL",
|
||||
openService: "Просмотреть сервис",
|
||||
restart: "Перезапустить",
|
||||
run: "Бегать",
|
||||
stop: "Остановить",
|
||||
view: "Открыть терминал",
|
||||
view: "Вид",
|
||||
},
|
||||
accessibility: {
|
||||
trigger: "Скрипты Workspace",
|
||||
openService: "Просмотреть сервис {{scriptName}}",
|
||||
openAt: "Откройте{{scriptName}}на{{label}}",
|
||||
viewTerminal: "Посмотреть терминал{{scriptName}}",
|
||||
runScript: "Запустите скрипт{{scriptName}}",
|
||||
stopScript: "Остановить{{scriptName}}",
|
||||
restartScript: "Перезапустить{{scriptName}}",
|
||||
copyUrl: "Скопировать URL {{scriptName}}",
|
||||
chooseUrl: "Выбрать URL для {{scriptName}}",
|
||||
script: "скрипт{{scriptName}}",
|
||||
},
|
||||
routes: {
|
||||
public: "Обратный прокси",
|
||||
paseo: "Memorable",
|
||||
direct: "Прямой адрес",
|
||||
},
|
||||
states: {
|
||||
exitCode: "выйти из{{code}}",
|
||||
startFailed: "Не удалось запустить{{scriptName}}",
|
||||
stopFailed: "Не удалось остановить{{scriptName}}",
|
||||
},
|
||||
},
|
||||
git: {
|
||||
@@ -810,12 +787,14 @@ export const ru: TranslationResources = {
|
||||
deletedFile: "Удалено",
|
||||
commits: {
|
||||
title: "Коммиты",
|
||||
countLabel: "{{count}} последних коммитов",
|
||||
legendLocal: "локально",
|
||||
legendRemote: "на удалённом",
|
||||
countLabel: "{{count}} коммитов впереди базы",
|
||||
fileDiffEmpty: "Нет изменений для отображения",
|
||||
fileDiffError: "Не удалось загрузить различия файла",
|
||||
loading: "Загрузка коммитов…",
|
||||
loadError: "Не удалось загрузить коммиты",
|
||||
empty: "Коммитов пока нет",
|
||||
empty: "Нет коммитов впереди базы",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
@@ -948,8 +927,6 @@ export const ru: TranslationResources = {
|
||||
actions: {
|
||||
menu: "Действия Workspace",
|
||||
newWorkspace: "Новое рабочее пространство",
|
||||
showMore: "Показать ещё",
|
||||
showLess: "Показать меньше",
|
||||
createWorkspaceFor: "Создайте новое рабочее пространство для{{projectName}}.",
|
||||
copyPath: "Копировать путь",
|
||||
copyBranchName: "Скопировать название ветки",
|
||||
@@ -1007,6 +984,10 @@ export const ru: TranslationResources = {
|
||||
refPicker: {
|
||||
startingRef: "Начальная ссылка",
|
||||
chooseStart: "Выберите, с чего начать",
|
||||
checkoutHint: "Проверьте {{noun}} {{numberPrefix}}{{number}}?",
|
||||
checkoutPr: "Проверьте {{noun}} {{numberPrefix}}{{number}}",
|
||||
dismissCheckoutHint:
|
||||
"Отклонить подсказку по оформлению заказа {{noun}} {{numberPrefix}}{{number}}",
|
||||
intoBase: "в {{baseRef}}",
|
||||
searching: "Идет поиск...",
|
||||
noMatchingRefs: "Нет подходящих ссылок.",
|
||||
@@ -1166,6 +1147,20 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Проверка доступности импорта…",
|
||||
"host-not-running": "Запустите управляемый Desktop хост перед импортом.",
|
||||
},
|
||||
actions: { import: "Импортировать", importing: "Импорт…", done: "Готово" },
|
||||
unavailable: "Импорт в настольном приложении недоступен.",
|
||||
complete: "Импорт завершён.",
|
||||
failed: "Не удалось выполнить импорт.",
|
||||
description: "Импорт проектов, настроек и рабочих деревьев с этого Mac.",
|
||||
sheetTitle: "Импорт из {{source}}",
|
||||
confirmation:
|
||||
"Paseo зарегистрирует допустимые репозитории, объединит поддерживаемые настройки проектов и подключит или пересоздаст готовые рабочие деревья. Данные {{source}} не изменятся.",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "Невозможно проверить статус установки CLI.",
|
||||
installFailed: "Невозможно установить PaseoCLI.",
|
||||
@@ -1499,25 +1494,6 @@ export const ru: TranslationResources = {
|
||||
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
||||
failedToLoad: "Не удалось загрузить файл",
|
||||
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
||||
editor: {
|
||||
fileSize: "Размер файла {{size}}",
|
||||
lines: "Строк: {{count}}",
|
||||
editorStatus: "Состояние редактора: {{status}}",
|
||||
unsavedChanges: "Несохранённые изменения",
|
||||
saving: "Сохранение...",
|
||||
saveFailed: "Не удалось сохранить",
|
||||
changedOnDisk: "Изменён на диске",
|
||||
vimMode: "Режим Vim {{mode}}",
|
||||
cursor: "Строка {{line}}, столбец {{column}}",
|
||||
preview: "Просмотр",
|
||||
source: "Исходник",
|
||||
unavailableTitle: "Файл недоступен на диске",
|
||||
conflictDescription: "Локальный буфер сохранён. Выберите версию, которую нужно оставить.",
|
||||
overwrite: "Перезаписать",
|
||||
reload: "Перезагрузить",
|
||||
reloadTitle: "Перезагрузить с диска?",
|
||||
reloadMessage: "Локальные изменения будут потеряны.",
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Изменения",
|
||||
@@ -1613,18 +1589,12 @@ export const ru: TranslationResources = {
|
||||
sections: {
|
||||
general: "Общий",
|
||||
appearance: "Появление",
|
||||
editor: "Редактор",
|
||||
shortcuts: "Ярлыки",
|
||||
integrations: "Интеграции",
|
||||
permissions: "Разрешения",
|
||||
diagnostics: "Диагностика",
|
||||
about: "О",
|
||||
},
|
||||
editor: {
|
||||
title: "Редактор",
|
||||
vimKeybindings: "Клавиши Vim",
|
||||
vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
|
||||
},
|
||||
hostSections: {
|
||||
connections: "Соединения",
|
||||
agents: "Agents",
|
||||
|
||||
@@ -466,7 +466,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
tabs: {
|
||||
loading: "正在加载...",
|
||||
modified: "未保存的更改",
|
||||
loadingAgentTitle: "正在加载 Agent 标题",
|
||||
emptyPane: "此窗格中没有标签。",
|
||||
fallback: {
|
||||
@@ -533,11 +532,6 @@ export const zhCN: TranslationResources = {
|
||||
failedToReloadAgent: "重新加载 Agent 失败",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "未保存的更改",
|
||||
unsavedMessage: "此标签页有尚未保存的更改。关闭将丢弃草稿。",
|
||||
closeWithoutSaving: "不保存并关闭",
|
||||
closePaneTitle: "关闭面板?",
|
||||
bulkUnsaved: "{{count}} 个标签页有未保存的更改。关闭将丢弃这些草稿。",
|
||||
close: "关闭",
|
||||
cancel: "取消",
|
||||
archive: "归档",
|
||||
@@ -584,34 +578,19 @@ export const zhCN: TranslationResources = {
|
||||
scripts: {
|
||||
title: "Scripts",
|
||||
actions: {
|
||||
chooseUrl: "选择 URL",
|
||||
copyUrl: "复制 URL",
|
||||
openService: "查看服务",
|
||||
restart: "重启",
|
||||
run: "运行",
|
||||
stop: "停止",
|
||||
view: "查看终端",
|
||||
view: "查看",
|
||||
},
|
||||
accessibility: {
|
||||
trigger: "Workspace scripts",
|
||||
openService: "查看 {{scriptName}} 服务",
|
||||
openAt: "在 {{label}} 打开 {{scriptName}}",
|
||||
viewTerminal: "查看 {{scriptName}} Terminal",
|
||||
runScript: "运行 {{scriptName}} script",
|
||||
stopScript: "停止 {{scriptName}}",
|
||||
restartScript: "重启 {{scriptName}}",
|
||||
copyUrl: "复制 {{scriptName}} 的 URL",
|
||||
chooseUrl: "选择 {{scriptName}} 的 URL",
|
||||
script: "{{scriptName}} script",
|
||||
},
|
||||
routes: {
|
||||
public: "反向代理",
|
||||
paseo: "Memorable",
|
||||
direct: "直接地址",
|
||||
},
|
||||
states: {
|
||||
exitCode: "exit {{code}}",
|
||||
startFailed: "启动 {{scriptName}} 失败",
|
||||
stopFailed: "停止 {{scriptName}} 失败",
|
||||
},
|
||||
},
|
||||
git: {
|
||||
@@ -780,12 +759,14 @@ export const zhCN: TranslationResources = {
|
||||
deletedFile: "已删除",
|
||||
commits: {
|
||||
title: "提交",
|
||||
countLabel: "最近 {{count}} 个提交",
|
||||
legendLocal: "本地",
|
||||
legendRemote: "已推送",
|
||||
countLabel: "领先基线 {{count}} 个提交",
|
||||
fileDiffEmpty: "没有可显示的更改",
|
||||
fileDiffError: "加载文件差异失败",
|
||||
loading: "正在加载提交…",
|
||||
loadError: "加载提交失败",
|
||||
empty: "暂无提交",
|
||||
empty: "没有领先基线的提交",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
@@ -916,8 +897,6 @@ export const zhCN: TranslationResources = {
|
||||
actions: {
|
||||
menu: "Workspace 操作",
|
||||
newWorkspace: "新建 workspace",
|
||||
showMore: "显示更多",
|
||||
showLess: "收起",
|
||||
createWorkspaceFor: "为 {{projectName}} 新建 workspace",
|
||||
copyPath: "复制路径",
|
||||
copyBranchName: "复制分支名称",
|
||||
@@ -974,6 +953,9 @@ export const zhCN: TranslationResources = {
|
||||
refPicker: {
|
||||
startingRef: "起始 ref",
|
||||
chooseStart: "选择起始位置",
|
||||
checkoutHint: "Checkout {{noun}} {{numberPrefix}}{{number}}?",
|
||||
checkoutPr: "Checkout {{noun}} {{numberPrefix}}{{number}}",
|
||||
dismissCheckoutHint: "忽略 {{noun}} {{numberPrefix}}{{number}} checkout 提示",
|
||||
intoBase: "进入 {{baseRef}}",
|
||||
searching: "正在搜索...",
|
||||
noMatchingRefs: "没有匹配的 refs。",
|
||||
@@ -1121,6 +1103,20 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "正在检查导入是否可用…",
|
||||
"host-not-running": "请先启动由桌面端管理的主机再导入。",
|
||||
},
|
||||
actions: { import: "导入", importing: "正在导入…", done: "完成" },
|
||||
unavailable: "桌面端导入不可用。",
|
||||
complete: "导入完成。",
|
||||
failed: "导入失败。",
|
||||
description: "从这台 Mac 导入项目、设置和工作树。",
|
||||
sheetTitle: "从 {{source}} 导入",
|
||||
confirmation:
|
||||
"Paseo 将注册有效仓库、合并支持的项目设置,并采用或重新创建已就绪的工作树。{{source}} 数据不会被修改。",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "无法检查 CLI 安装状态。",
|
||||
installFailed: "无法安装 Paseo CLI。",
|
||||
@@ -1446,25 +1442,6 @@ export const zhCN: TranslationResources = {
|
||||
binaryPreviewUnavailable: "二进制预览不可用",
|
||||
failedToLoad: "加载文件失败",
|
||||
failedToLoadPreview: "加载文件预览失败",
|
||||
editor: {
|
||||
fileSize: "文件大小 {{size}}",
|
||||
lines: "{{count}} 行",
|
||||
editorStatus: "编辑器状态 {{status}}",
|
||||
unsavedChanges: "未保存的更改",
|
||||
saving: "正在保存...",
|
||||
saveFailed: "保存失败",
|
||||
changedOnDisk: "磁盘上的文件已更改",
|
||||
vimMode: "Vim 模式 {{mode}}",
|
||||
cursor: "第 {{line}} 行,第 {{column}} 列",
|
||||
preview: "预览",
|
||||
source: "源代码",
|
||||
unavailableTitle: "磁盘上的文件不可用",
|
||||
conflictDescription: "本地内容已保留。请选择要保留的版本。",
|
||||
overwrite: "覆盖",
|
||||
reload: "重新加载",
|
||||
reloadTitle: "从磁盘重新加载?",
|
||||
reloadMessage: "本地更改将丢失。",
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "更改",
|
||||
@@ -1560,18 +1537,12 @@ export const zhCN: TranslationResources = {
|
||||
sections: {
|
||||
general: "通用",
|
||||
appearance: "外观",
|
||||
editor: "编辑器",
|
||||
shortcuts: "快捷键",
|
||||
integrations: "集成",
|
||||
permissions: "权限",
|
||||
diagnostics: "诊断",
|
||||
about: "关于",
|
||||
},
|
||||
editor: {
|
||||
title: "编辑器",
|
||||
vimKeybindings: "Vim 键位",
|
||||
vimHint: "适用于网页和桌面端的源文件。",
|
||||
},
|
||||
hostSections: {
|
||||
connections: "连接",
|
||||
agents: "Agents",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* Z-index scale within overlay root:
|
||||
* - Modal backdrop/content: 10
|
||||
* - Toast: 20
|
||||
* - Tooltip: 30
|
||||
*/
|
||||
export function getOverlayRoot(): HTMLElement {
|
||||
let el = document.getElementById("overlay-root");
|
||||
@@ -23,5 +22,4 @@ export function getOverlayRoot(): HTMLElement {
|
||||
export const OVERLAY_Z = {
|
||||
modal: 10,
|
||||
toast: 20,
|
||||
tooltip: 30,
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
@@ -7,37 +7,115 @@ import {
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startHostRuntimeBootstrap,
|
||||
} from "./host-runtime-bootstrap";
|
||||
import type {
|
||||
DaemonStartCondition,
|
||||
StartDaemonIfEnabledInput,
|
||||
} from "@/runtime/daemon-start-service";
|
||||
|
||||
function createFakeStore() {
|
||||
return { boot: vi.fn() };
|
||||
}
|
||||
|
||||
function createFakeDaemonStartService() {
|
||||
return {
|
||||
start: vi.fn(async () => ({ ok: true as const })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("startHostRuntimeBootstrap", () => {
|
||||
it("boots the host registry and starts the managed-daemon decision as one operation", () => {
|
||||
it("fires boot and daemon-start without awaiting the daemon-start promise", () => {
|
||||
const events: string[] = [];
|
||||
const shouldStartDaemon = async () => true;
|
||||
const store = {
|
||||
boot: () => {
|
||||
boot: vi.fn(() => {
|
||||
events.push("boot");
|
||||
},
|
||||
}),
|
||||
};
|
||||
let receivedCondition: DaemonStartCondition | null = null;
|
||||
const daemonStartService = {
|
||||
startIfEnabled: async (input: StartDaemonIfEnabledInput) => {
|
||||
receivedCondition = input.shouldStart;
|
||||
events.push("daemon-start-decision");
|
||||
start: vi.fn(async () => {
|
||||
events.push("daemon-start");
|
||||
return { ok: true as const };
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon,
|
||||
shouldStartDaemon: true,
|
||||
});
|
||||
|
||||
expect(events).toEqual(["boot", "daemon-start-decision"]);
|
||||
expect(receivedCondition).toBe(shouldStartDaemon);
|
||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
|
||||
expect(events).toEqual(["boot", "daemon-start"]);
|
||||
});
|
||||
|
||||
it("skips daemon-start when shouldStartDaemon is false", () => {
|
||||
const store = createFakeStore();
|
||||
const daemonStartService = createFakeDaemonStartService();
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: false,
|
||||
});
|
||||
|
||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips daemon-start when the startup gate resolves false", async () => {
|
||||
const store = createFakeStore();
|
||||
const daemonStartService = createFakeDaemonStartService();
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: async () => false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces gate rejection to onGateError without starting the daemon", async () => {
|
||||
const store = createFakeStore();
|
||||
const daemonStartService = createFakeDaemonStartService();
|
||||
const onGateError = vi.fn();
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: async () => {
|
||||
throw new Error("settings file unreadable");
|
||||
},
|
||||
onGateError,
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(onGateError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||
expect(onGateError).toHaveBeenCalledWith(expect.stringContaining("settings file unreadable"));
|
||||
});
|
||||
|
||||
it("does not await the daemon-start promise", () => {
|
||||
const store = createFakeStore();
|
||||
let resolveStart: ((value: { ok: true }) => void) | undefined;
|
||||
const daemonStartService = {
|
||||
start: vi.fn(
|
||||
() =>
|
||||
new Promise<{ ok: true }>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: true,
|
||||
});
|
||||
|
||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveStart?.({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user