mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c05f69402 | ||
|
|
8fdc41f06c | ||
|
|
928c124356 | ||
|
|
0a398833d8 | ||
|
|
71c66823aa | ||
|
|
a1821d5863 | ||
|
|
5db070a4d9 | ||
|
|
461be47c91 | ||
|
|
92d7066601 | ||
|
|
d28e174b38 | ||
|
|
a257a1c5f6 | ||
|
|
144f951a79 | ||
|
|
51fea4b7e0 | ||
|
|
e98ba1beb1 | ||
|
|
3bbbfbc880 | ||
|
|
f86226a196 | ||
|
|
120842264e | ||
|
|
a017ddfc29 | ||
|
|
03dcda7c41 | ||
|
|
ada0955334 | ||
|
|
ca5c786ccf | ||
|
|
5ae53c7e55 | ||
|
|
c800c6a3f1 | ||
|
|
adc9b3b43c | ||
|
|
5d18edd31e | ||
|
|
6c764f211a | ||
|
|
28a27b02d3 | ||
|
|
860fcb2e35 | ||
|
|
4c72bf0209 | ||
|
|
8c639fd796 | ||
|
|
aa656772ef | ||
|
|
4dd783c36a | ||
|
|
e6a316456d | ||
|
|
3c2339f841 | ||
|
|
61a9117a6b | ||
|
|
1f0285c06b | ||
|
|
a689ccb0af | ||
|
|
cac71ee8f5 | ||
|
|
a743a22496 | ||
|
|
b2c6476817 | ||
|
|
6af1379357 | ||
|
|
15d3091104 | ||
|
|
ebaecace2b |
@@ -239,8 +239,13 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shard: [1, 2, 3, 4]
|
include:
|
||||||
name: playwright (shard ${{ matrix.shard }}/4)
|
- { label: "shard 1/4", shard: 1, desktop: false }
|
||||||
|
- { label: "shard 2/4", shard: 2, desktop: false }
|
||||||
|
- { label: "shard 3/4", shard: 3, desktop: false }
|
||||||
|
- { label: "shard 4/4", shard: 4, desktop: false }
|
||||||
|
- { label: "desktop overlay", shard: "desktop", desktop: true }
|
||||||
|
name: playwright (${{ matrix.label }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||||
@@ -276,13 +281,19 @@ jobs:
|
|||||||
run: npm run build:server
|
run: npm run build:server
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
- name: Install agent CLIs for provider tests
|
||||||
|
if: ${{ !matrix.desktop }}
|
||||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||||
|
|
||||||
- name: Run Playwright E2E tests
|
- name: Run Playwright E2E tests
|
||||||
|
if: ${{ !matrix.desktop }}
|
||||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||||
env:
|
env:
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|
||||||
|
- name: Run desktop-overlay Playwright tests
|
||||||
|
if: ${{ matrix.desktop }}
|
||||||
|
run: npm run test:e2e:desktop --workspace=@getpaseo/app
|
||||||
|
|
||||||
- name: Upload test artifacts
|
- name: Upload test artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
name: Deploy Relay
|
name: Deploy Relay
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
|
||||||
branches: [main]
|
# A release or main push must not redeploy the temporary Cloudflare bridge.
|
||||||
paths:
|
|
||||||
- "packages/relay/**"
|
|
||||||
- ".github/workflows/deploy-relay.yml"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -1,5 +1,47 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.106 - 2026-07-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Approve Codex MCP permission requests in Paseo ([#2001](https://github.com/getpaseo/paseo/pull/2001))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- ACP provider catalog updated to the latest registry versions
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reduced mobile chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989))
|
||||||
|
- OpenCode sessions start reliably instead of occasionally losing the first turn ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger))
|
||||||
|
- Switching between workspaces no longer flashes a white screen
|
||||||
|
- Pi keeps your existing MCP tools and settings when Paseo adds its own ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger))
|
||||||
|
|
||||||
|
## 0.1.105 - 2026-07-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Browse changed files as a collapsible folder tree or flat list ([#1918](https://github.com/getpaseo/paseo/pull/1918), [#1945](https://github.com/getpaseo/paseo/pull/1945) by [@cleiter](https://github.com/cleiter))
|
||||||
|
- Always expand agent reasoning with a new appearance setting ([#1943](https://github.com/getpaseo/paseo/pull/1943) by [@mcowger](https://github.com/mcowger))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Project picker finds folders with fuzzy search and native desktop browsing ([#1968](https://github.com/getpaseo/paseo/pull/1968))
|
||||||
|
- Large workspace sidebars stay responsive ([#1966](https://github.com/getpaseo/paseo/pull/1966))
|
||||||
|
- Generated workspace names and Git text can use MiniMax M3 ([#1955](https://github.com/getpaseo/paseo/pull/1955) by [@octo-patch](https://github.com/octo-patch))
|
||||||
|
- Cursor now exposes thinking and fast mode ([#1952](https://github.com/getpaseo/paseo/pull/1952))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Codex stays active and streams correctly while subagents run ([#1967](https://github.com/getpaseo/paseo/pull/1967))
|
||||||
|
- Android audio interruptions no longer crash voice mode or leave dictation stuck ([#1941](https://github.com/getpaseo/paseo/pull/1941))
|
||||||
|
- Mobile sidebars stay in sync and retain swipe-to-open gestures ([#1953](https://github.com/getpaseo/paseo/pull/1953), [#1976](https://github.com/getpaseo/paseo/pull/1976))
|
||||||
|
- Pi text-only models accept image prompts without breaking the session ([#1960](https://github.com/getpaseo/paseo/pull/1960))
|
||||||
|
- App render failures show a retryable recovery screen instead of a blank screen ([#1924](https://github.com/getpaseo/paseo/pull/1924))
|
||||||
|
- Pi context usage remains visible with older Oh My Pi versions ([#1886](https://github.com/getpaseo/paseo/pull/1886) by [@theslava](https://github.com/theslava))
|
||||||
|
- Provider usage popovers no longer error when opened and closed quickly ([#1885](https://github.com/getpaseo/paseo/pull/1885) by [@theslava](https://github.com/theslava))
|
||||||
|
- Mobile workspace menus hide desktop-only shortcut badges ([#1964](https://github.com/getpaseo/paseo/pull/1964))
|
||||||
|
|
||||||
## 0.1.104 - 2026-07-08
|
## 0.1.104 - 2026-07-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
|||||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||||
|
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||||
|
|||||||
@@ -185,6 +185,15 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
|
|||||||
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
||||||
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
||||||
|
|
||||||
|
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
|
||||||
|
configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy,
|
||||||
|
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
|
||||||
|
request without `cwd` searches the host home for absolute project paths; a request with `cwd`
|
||||||
|
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
|
||||||
|
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
|
||||||
|
correlated daemon response. The legacy `directories` response field remains a projection of the
|
||||||
|
typed `entries` list.
|
||||||
|
|
||||||
**Binary frames (terminal stream protocol):**
|
**Binary frames (terminal stream protocol):**
|
||||||
|
|
||||||
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ For testing rules, see [testing.md](testing.md).
|
|||||||
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
|
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
|
||||||
- Never define components inside other components. Module-scope only.
|
- Never define components inside other components. Module-scope only.
|
||||||
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
||||||
|
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
|
||||||
|
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
|
||||||
|
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.
|
||||||
|
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
|
||||||
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
||||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||||
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
|||||||
| `qwen3-coder-next` | Optimized for coding |
|
| `qwen3-coder-next` | Optimized for coding |
|
||||||
| `kimi-k2.5` | Vision capable |
|
| `kimi-k2.5` | Vision capable |
|
||||||
| `glm-5` | Zhipu GLM |
|
| `glm-5` | Zhipu GLM |
|
||||||
| `MiniMax-M2.5` | MiniMax |
|
| `MiniMax-M3` | MiniMax |
|
||||||
|
|
||||||
**Additional models (pay-as-you-go):**
|
**Additional models (pay-as-you-go):**
|
||||||
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
||||||
|
|||||||
+1
-1
@@ -218,7 +218,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
|||||||
- Raw DOM APIs without an `isWeb` guard.
|
- Raw DOM APIs without an `isWeb` guard.
|
||||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||||
- Color changes for disabled state. Opacity only.
|
- Color changes for disabled state. Opacity only.
|
||||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
|
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
|
||||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ Keep workspace identity and retention outside native-stack `getId` and
|
|||||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||||
reordering an already-mounted workspace screen.
|
reordering an already-mounted workspace screen.
|
||||||
|
|
||||||
|
Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every
|
||||||
|
Expo Router stack. React Navigation otherwise paints each native stack screen
|
||||||
|
with its light default background. A screen-level wrapper can hide that surface
|
||||||
|
while settled, but Android may expose it for one frame when navigation crosses
|
||||||
|
from a nested stack to its parent stack. This is especially visible when an
|
||||||
|
app-wide route such as `/new` opens from a dark workspace.
|
||||||
|
|
||||||
|
Do not read the active theme with `useUnistyles()` in a layout to build
|
||||||
|
`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through
|
||||||
|
a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||||
|
every Unistyles runtime update.
|
||||||
|
|
||||||
## Regression Shape
|
## Regression Shape
|
||||||
|
|
||||||
Pure helper tests are useful but not enough. The failure mode here is native
|
Pure helper tests are useful but not enough. The failure mode here is native
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
|
|
||||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||||
|
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
|
||||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Mobile panels
|
||||||
|
|
||||||
|
Compact layouts have three mutually exclusive destinations:
|
||||||
|
|
||||||
|
- `agent-list` on the left
|
||||||
|
- `agent` in the center
|
||||||
|
- `file-explorer` on the right
|
||||||
|
|
||||||
|
They are one interaction, not two independent drawers. The implementation lives in
|
||||||
|
`packages/app/src/mobile-panels/`.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
React/Zustand owns the durable intent:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface MobilePanelSelection {
|
||||||
|
target: "agent-list" | "agent" | "file-explorer";
|
||||||
|
revision: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
||||||
|
Compact panel selection is not persisted; a cold start begins at `agent`.
|
||||||
|
|
||||||
|
The UI worklet owns transient motion:
|
||||||
|
|
||||||
|
- one normalized position (`-1` left, `0` center, `1` right)
|
||||||
|
- the current motion target
|
||||||
|
- the active gesture's starting revision
|
||||||
|
- the last settled target
|
||||||
|
|
||||||
|
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
|
||||||
|
receive pointer events. Worklets never own `display` or `pointerEvents`.
|
||||||
|
|
||||||
|
## Why one position
|
||||||
|
|
||||||
|
Both transforms and both backdrop opacities are derived from the same normalized position. Window
|
||||||
|
width is only a projection input. Rotation changes the projection, not the panel state.
|
||||||
|
|
||||||
|
This makes these invalid states unrepresentable:
|
||||||
|
|
||||||
|
- a panel and its backdrop disagreeing
|
||||||
|
- left and right drawers both claiming to be open
|
||||||
|
- a width-sync effect resetting an active drag
|
||||||
|
- one animation context settling a transition owned by the other
|
||||||
|
|
||||||
|
Do not add another panel translate shared value, backdrop shared value, or width synchronization
|
||||||
|
effect.
|
||||||
|
|
||||||
|
## Ordering and interruption
|
||||||
|
|
||||||
|
A gesture captures the current revision when it becomes active. Per-frame updates are accepted only
|
||||||
|
while that revision still owns the gesture.
|
||||||
|
|
||||||
|
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
||||||
|
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
||||||
|
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
|
||||||
|
its target and revision still match the canonical command.
|
||||||
|
|
||||||
|
Manual gesture arbitration has two phases:
|
||||||
|
|
||||||
|
1. Before activation, determine whether horizontal intent may begin.
|
||||||
|
2. After activation, stop running begin checks and let the active revision own updates.
|
||||||
|
|
||||||
|
Re-running the begin gate after activation self-cancels the gesture because an active gesture is, by
|
||||||
|
definition, no longer eligible to begin.
|
||||||
|
|
||||||
|
## Integration rules
|
||||||
|
|
||||||
|
- Callers request semantic targets through `panel-store`; they never write shared values.
|
||||||
|
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
|
||||||
|
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
|
||||||
|
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
|
||||||
|
so its injected `collapsable={false}` reaches Android/Fabric.
|
||||||
|
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
|
||||||
|
styles in sidebar components.
|
||||||
|
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||||
|
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||||
|
has caused native crashes.
|
||||||
|
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
||||||
|
prop commit from resurrecting a closed overlay.
|
||||||
|
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
||||||
|
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||||
|
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
||||||
|
same active signal without adding another layout root. Persistent animations, timers, polling, and
|
||||||
|
shared clocks must subscribe to that signal and stop when their final visible consumer leaves.
|
||||||
|
- Synchronized step animations use one wall-clock-aligned source. Register a local shared value only
|
||||||
|
while its retained panel is active so hidden animated styles remain mounted without receiving clock
|
||||||
|
updates. Do not give every instance its own loop or leave hidden styles subscribed to the source.
|
||||||
|
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
||||||
|
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||||
|
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
||||||
|
- The newly active panel must be included in the same render that changes selection. Adding it from an
|
||||||
|
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
||||||
|
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||||
|
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||||
|
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
|
||||||
|
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
|
||||||
|
sequence there whenever ownership or ordering changes.
|
||||||
+3
-1
@@ -26,7 +26,9 @@ Pi is a process-backed provider. Paseo requires the user to have the `pi` binary
|
|||||||
|
|
||||||
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.
|
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 MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
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.
|
||||||
|
|
||||||
|
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||||
|
|
||||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
|
|||||||
|
|
||||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||||
|
|
||||||
|
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
|
||||||
|
|
||||||
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
||||||
|
|
||||||
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
|
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Test setup
|
### Test setup
|
||||||
|
|
||||||
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
|
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ The invariant is:
|
|||||||
|
|
||||||
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
|
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
|
||||||
|
|
||||||
|
Tool output is bounded before it enters either delivery path. Canonical shell tool output and failed
|
||||||
|
shell error text are capped at 64 KiB of UTF-8 data, and the same bounded item is used for durable
|
||||||
|
timeline rows and live stream events. Provider history hydration applies the same rule so reopening
|
||||||
|
an agent cannot restore an oversized tool payload.
|
||||||
|
|
||||||
## Presence is not delivery
|
## Presence is not delivery
|
||||||
|
|
||||||
Client heartbeat reports presence:
|
Client heartbeat reports presence:
|
||||||
|
|||||||
@@ -90,6 +90,19 @@ When a reusable component has a prop whose whole job is dynamic geometry, make t
|
|||||||
|
|
||||||
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
|
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
|
||||||
|
|
||||||
|
Do not register an existing Unistyles style inside another `StyleSheet.create` either. That also combines two metadata identities into one object. Reuse the original style directly at the component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wrong: sharedStyles.row already carries Unistyles metadata.
|
||||||
|
const styles = StyleSheet.create({ row: sharedStyles.row });
|
||||||
|
<View style={styles.row} />;
|
||||||
|
|
||||||
|
// Right: one registered style identity reaches the native view.
|
||||||
|
<View style={sharedStyles.row} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
This mistake once produced tens of thousands of warnings from retained sidebar rows. Because React Native captures component stacks for warnings, the warning loop itself can consume enough CPU and memory to make the app appear blank.
|
||||||
|
|
||||||
## Main Gotcha: `contentContainerStyle`
|
## Main Gotcha: `contentContainerStyle`
|
||||||
|
|
||||||
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-RtZABQl17BlUyvFiFw9W5g2E/dpNlrOWpuCdV8wZk5o=
|
sha256-lYvP0RKgxshO68+bjkkUXX9YJtzgoGIp8kGdGFYOT+o=
|
||||||
|
|||||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -35152,7 +35152,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -36170,12 +36170,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.1.104",
|
"@getpaseo/client": "0.1.106",
|
||||||
"@getpaseo/protocol": "0.1.104",
|
"@getpaseo/protocol": "0.1.106",
|
||||||
"@getpaseo/server": "0.1.104",
|
"@getpaseo/server": "0.1.106",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -36421,10 +36421,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.1.104",
|
"@getpaseo/protocol": "0.1.106",
|
||||||
"@getpaseo/relay": "0.1.104",
|
"@getpaseo/relay": "0.1.106",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -36435,7 +36435,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -36678,7 +36678,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -37574,7 +37574,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -37806,7 +37806,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -37819,7 +37819,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38037,15 +38037,15 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.1.104",
|
"@getpaseo/client": "0.1.106",
|
||||||
"@getpaseo/highlight": "0.1.104",
|
"@getpaseo/highlight": "0.1.106",
|
||||||
"@getpaseo/protocol": "0.1.104",
|
"@getpaseo/protocol": "0.1.106",
|
||||||
"@getpaseo/relay": "0.1.104",
|
"@getpaseo/relay": "0.1.106",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -38582,7 +38582,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -37,6 +37,91 @@ test.describe("Agent stream UI", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps the active Markdown root mounted across streamed text updates", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const agent = await startRunningMockAgent(page, {
|
||||||
|
prefix: "stream-markdown-root-",
|
||||||
|
model: "one-minute-stream",
|
||||||
|
prompt: "Stream for Markdown root stability test.",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const assistantMessage = page.getByTestId("assistant-message").last();
|
||||||
|
await expect(assistantMessage).toContainText("walking through", { timeout: 30_000 });
|
||||||
|
|
||||||
|
const activeBlock = assistantMessage.locator(":scope > *").last();
|
||||||
|
const initialText = (await activeBlock.textContent()) ?? "";
|
||||||
|
const activeBlockHandle = await activeBlock.elementHandle();
|
||||||
|
if (!activeBlockHandle) {
|
||||||
|
throw new Error("Expected the active assistant message to contain a block");
|
||||||
|
}
|
||||||
|
const markdownRoot = await activeBlock.locator(":scope > *").first().elementHandle();
|
||||||
|
if (!markdownRoot) {
|
||||||
|
throw new Error("Expected the active assistant block to contain a Markdown root");
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.evaluate((block) => {
|
||||||
|
const evidence = {
|
||||||
|
addedNodes: 0,
|
||||||
|
characterDataMutations: 0,
|
||||||
|
removedNodes: 0,
|
||||||
|
};
|
||||||
|
const observer = new MutationObserver((records) => {
|
||||||
|
for (const record of records) {
|
||||||
|
evidence.addedNodes += record.addedNodes.length;
|
||||||
|
evidence.removedNodes += record.removedNodes.length;
|
||||||
|
if (record.type === "characterData") {
|
||||||
|
evidence.characterDataMutations += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(block, { characterData: true, childList: true, subtree: true });
|
||||||
|
Object.assign(window, {
|
||||||
|
__markdownRootEvidence: evidence,
|
||||||
|
__markdownRootObserver: observer,
|
||||||
|
});
|
||||||
|
}, activeBlockHandle);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await activeBlock.textContent()) ?? "").length)
|
||||||
|
.toBeGreaterThan(initialText.length + 80);
|
||||||
|
|
||||||
|
const evidence = await page.evaluate((root) => {
|
||||||
|
const state = window as typeof window & {
|
||||||
|
__markdownRootEvidence?: {
|
||||||
|
addedNodes: number;
|
||||||
|
characterDataMutations: number;
|
||||||
|
removedNodes: number;
|
||||||
|
};
|
||||||
|
__markdownRootObserver?: MutationObserver;
|
||||||
|
};
|
||||||
|
state.__markdownRootObserver?.disconnect();
|
||||||
|
const messages = document.querySelectorAll('[data-testid="assistant-message"]');
|
||||||
|
const message = messages.item(messages.length - 1);
|
||||||
|
const block = message?.lastElementChild;
|
||||||
|
return {
|
||||||
|
...state.__markdownRootEvidence,
|
||||||
|
connected: root.isConnected,
|
||||||
|
sameRoot: block?.firstElementChild === root,
|
||||||
|
};
|
||||||
|
}, markdownRoot);
|
||||||
|
|
||||||
|
await testInfo.attach("markdown-root-stability", {
|
||||||
|
body: JSON.stringify(evidence, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
expect(evidence.connected).toBe(true);
|
||||||
|
expect(evidence.sameRoot).toBe(true);
|
||||||
|
expect(
|
||||||
|
evidence.removedNodes,
|
||||||
|
`Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`,
|
||||||
|
).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
|
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
const agent = await seedMockAgentWorkspace({
|
const agent = await seedMockAgentWorkspace({
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import { test, expect, type Page } from "./fixtures";
|
import { test, expect, type Page } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||||
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
@@ -10,7 +12,7 @@ function workspaceRowTestId(workspaceId: string): string {
|
|||||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
@@ -20,10 +22,6 @@ async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promis
|
|||||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||||
await kebab.click();
|
await kebab.click();
|
||||||
|
|
||||||
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
|
|
||||||
// user-confirmed archive proceeds deterministically.
|
|
||||||
page.once("dialog", (dialog) => void dialog.accept());
|
|
||||||
|
|
||||||
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
|
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
|
||||||
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
|
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
|
||||||
await archiveItem.click();
|
await archiveItem.click();
|
||||||
@@ -50,7 +48,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
|
|||||||
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
|
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
|
||||||
await page.getByTestId("sidebar-add-project").click();
|
await page.getByTestId("sidebar-add-project").click();
|
||||||
|
|
||||||
const input = page.getByPlaceholder("Type a directory path...");
|
const input = page.getByTestId("project-picker-input");
|
||||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||||
await input.fill(projectPath);
|
await input.fill(projectPath);
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
@@ -74,6 +72,26 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Project picker search", () => {
|
test.describe("Project picker search", () => {
|
||||||
|
test("opens a project from a fuzzy directory-name search", async ({
|
||||||
|
page,
|
||||||
|
projectPickerFixture,
|
||||||
|
}) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarProjectListReady(page);
|
||||||
|
await page.getByTestId("sidebar-add-project").click();
|
||||||
|
|
||||||
|
const input = page.getByTestId("project-picker-input");
|
||||||
|
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||||
|
await input.fill(projectPickerFixture.fuzzyQuery);
|
||||||
|
|
||||||
|
const suggestion = page.getByText(projectPickerFixture.projectName, { exact: false }).first();
|
||||||
|
await expect(suggestion).toBeVisible({ timeout: 30_000 });
|
||||||
|
await suggestion.click();
|
||||||
|
|
||||||
|
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||||
|
projectPickerFixture.rememberProjectId(projectId);
|
||||||
|
});
|
||||||
|
|
||||||
test("shows a loading state after typing while directory suggestions are pending", async ({
|
test("shows a loading state after typing while directory suggestions are pending", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -81,7 +99,7 @@ test.describe("Project picker search", () => {
|
|||||||
await waitForSidebarProjectListReady(page);
|
await waitForSidebarProjectListReady(page);
|
||||||
await page.getByTestId("sidebar-add-project").click();
|
await page.getByTestId("sidebar-add-project").click();
|
||||||
|
|
||||||
const input = page.getByPlaceholder("Type a directory path...");
|
const input = page.getByTestId("project-picker-input");
|
||||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||||
await input.fill("paseo-loading-state-no-match");
|
await input.fill("paseo-loading-state-no-match");
|
||||||
|
|
||||||
@@ -138,17 +156,21 @@ test.describe("Project with no workspaces persists", () => {
|
|||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||||
|
await expect(workspaceRow).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
await workspaceRow.click();
|
||||||
|
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||||
|
|
||||||
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
|
await archiveWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||||
|
|
||||||
// The workspace row goes away, but its project parent stays and exposes a
|
// The workspace row goes away, but its project parent stays and exposes a
|
||||||
// child row for creating the next workspace.
|
// child row for creating the next workspace.
|
||||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(newWorkspaceRow).toContainText("New workspace");
|
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test";
|
import { test as base, expect, type Page } from "@playwright/test";
|
||||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||||
|
import {
|
||||||
|
createProjectPickerFixture,
|
||||||
|
removeProjectPickerFixture,
|
||||||
|
type ProjectPickerFixture,
|
||||||
|
} from "./helpers/project-picker-fixture";
|
||||||
|
import { connectSeedClient } from "./helpers/seed-client";
|
||||||
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||||
|
|
||||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||||
|
|
||||||
|
interface TrackedProjectPickerFixture extends ProjectPickerFixture {
|
||||||
|
rememberProjectId: (projectId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||||
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||||
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||||
// reliably for every test that uses this `test` object.
|
// reliably for every test that uses this `test` object.
|
||||||
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
const test = base.extend<{
|
||||||
|
paseoE2ESetup: void;
|
||||||
|
projectPickerFixture: TrackedProjectPickerFixture;
|
||||||
|
withWorkspace: WithWorkspace;
|
||||||
|
}>({
|
||||||
baseURL: async ({}, provide) => {
|
baseURL: async ({}, provide) => {
|
||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!metroPort) {
|
if (!metroPort) {
|
||||||
@@ -102,6 +116,30 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
|||||||
},
|
},
|
||||||
{ auto: true },
|
{ auto: true },
|
||||||
],
|
],
|
||||||
|
projectPickerFixture: async ({}, provide) => {
|
||||||
|
const resource = await createProjectPickerFixture();
|
||||||
|
const { fixture } = resource;
|
||||||
|
let projectId: string | null = null;
|
||||||
|
try {
|
||||||
|
await provide({
|
||||||
|
...fixture,
|
||||||
|
rememberProjectId: (openedProjectId) => {
|
||||||
|
projectId = openedProjectId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
const client = await connectSeedClient();
|
||||||
|
try {
|
||||||
|
await removeProjectPickerFixture(client, fixture, projectId);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await resource.removeDirectory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
withWorkspace: async ({ page }, provide) => {
|
withWorkspace: async ({ page }, provide) => {
|
||||||
const handle = createWithWorkspace(page);
|
const handle = createWithWorkspace(page);
|
||||||
await provide(handle.withWorkspace);
|
await provide(handle.withWorkspace);
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface DesktopBridgeConfig {
|
|||||||
* false so tests that only assert copy don't inadvertently trigger state changes.
|
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||||
*/
|
*/
|
||||||
confirmShouldAccept?: boolean;
|
confirmShouldAccept?: boolean;
|
||||||
|
dialogOpenResult?: string | string[] | null;
|
||||||
editorTargets?: DesktopEditorTargetConfig[];
|
editorTargets?: DesktopEditorTargetConfig[];
|
||||||
editorRecordPath?: string;
|
editorRecordPath?: string;
|
||||||
}
|
}
|
||||||
@@ -96,6 +97,7 @@ export interface ConfirmDialogCall {
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||||
|
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +159,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
dialog: {
|
dialog: {
|
||||||
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
||||||
|
open: (options?: Record<string, unknown>) => Promise<string | string[] | null>;
|
||||||
};
|
};
|
||||||
getPendingOpenProject: () => Promise<string | null>;
|
getPendingOpenProject: () => Promise<string | null>;
|
||||||
events: { on: () => Promise<() => void> };
|
events: { on: () => Promise<() => void> };
|
||||||
@@ -249,6 +252,10 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
};
|
};
|
||||||
return cfg.confirmShouldAccept ?? false;
|
return cfg.confirmShouldAccept ?? false;
|
||||||
},
|
},
|
||||||
|
open: async (options?: Record<string, unknown>) => {
|
||||||
|
window.__capturedDialogOpenCalls.push(options);
|
||||||
|
return cfg.dialogOpenResult ?? null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
getPendingOpenProject: async () => null,
|
getPendingOpenProject: async () => null,
|
||||||
events: { on: async () => () => undefined },
|
events: { on: async () => () => undefined },
|
||||||
@@ -263,10 +270,18 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.__capturedDialogOpenCalls = [];
|
||||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
|
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
|
||||||
}, config);
|
}, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitForDirectoryDialog(
|
||||||
|
page: Page,
|
||||||
|
): Promise<Record<string, unknown> | undefined> {
|
||||||
|
await expect.poll(() => page.evaluate(() => window.__capturedDialogOpenCalls.length)).toBe(1);
|
||||||
|
return page.evaluate(() => window.__capturedDialogOpenCalls[0]);
|
||||||
|
}
|
||||||
|
|
||||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { SeedDaemonClient } from "./seed-client";
|
||||||
|
|
||||||
|
export interface ProjectPickerFixture {
|
||||||
|
projectPath: string;
|
||||||
|
projectName: string;
|
||||||
|
fuzzyQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectPickerFixtureResource {
|
||||||
|
fixture: ProjectPickerFixture;
|
||||||
|
removeDirectory: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProjectPickerFixture(): Promise<ProjectPickerFixtureResource> {
|
||||||
|
const root = await mkdtemp(path.join(homedir(), "paseo-e2e-project-picker-"));
|
||||||
|
const nonce = randomUUID().replaceAll("-", "").slice(0, 8);
|
||||||
|
const projectPath = path.join(root, "client", "team", `paseo-desktop-fuzzy-target-${nonce}`);
|
||||||
|
await mkdir(projectPath, { recursive: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
fixture: {
|
||||||
|
projectPath,
|
||||||
|
projectName: path.basename(projectPath),
|
||||||
|
fuzzyQuery: `psodfzt${nonce}`,
|
||||||
|
},
|
||||||
|
removeDirectory: () => rm(root, { recursive: true, force: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeProjectPickerFixture(
|
||||||
|
client: SeedDaemonClient,
|
||||||
|
fixture: ProjectPickerFixture,
|
||||||
|
knownProjectId: string | null = null,
|
||||||
|
): Promise<void> {
|
||||||
|
let projectId = knownProjectId;
|
||||||
|
if (!projectId) {
|
||||||
|
const lookup = await client.addProject(fixture.projectPath);
|
||||||
|
projectId = lookup.project?.projectId ?? null;
|
||||||
|
if (!projectId) {
|
||||||
|
throw new Error(lookup.error ?? "Could not resolve project picker fixture for cleanup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.removeProject(projectId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function expectOpenedProject(page: Page, projectName: string): Promise<string> {
|
||||||
|
const projectRow = page
|
||||||
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
|
.filter({ hasText: projectName })
|
||||||
|
.first();
|
||||||
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const testId = await projectRow.getAttribute("data-testid");
|
||||||
|
expect(testId).not.toBeNull();
|
||||||
|
return testId!.replace("sidebar-project-row-", "");
|
||||||
|
}
|
||||||
@@ -39,10 +39,9 @@ export async function clickArchiveWorkspaceMenuItem(
|
|||||||
await archiveItem.click();
|
await archiveItem.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||||
// A clean worktree archives with no prompt; if the host reports unsynced work the app
|
// A clean workspace archives with no prompt. Managed worktree backing may raise
|
||||||
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
|
// a browser confirm for unsynced work, so accept it when present.
|
||||||
// either way.
|
|
||||||
page.once("dialog", (dialog) => void dialog.accept());
|
page.once("dialog", (dialog) => void dialog.accept());
|
||||||
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
|
||||||
|
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
|
|
||||||
|
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires Metro's Electron platform overlay");
|
||||||
|
|
||||||
|
test("Browse opens the folder selected by the desktop dialog", async ({
|
||||||
|
page,
|
||||||
|
projectPickerFixture,
|
||||||
|
}) => {
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId: getServerId(),
|
||||||
|
manageBuiltInDaemon: false,
|
||||||
|
dialogOpenResult: projectPickerFixture.projectPath,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
|
||||||
|
await page.getByTestId("sidebar-add-project").click();
|
||||||
|
const browse = page.getByRole("button", { name: "Browse…" });
|
||||||
|
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||||
|
await browse.click();
|
||||||
|
|
||||||
|
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||||
|
projectPickerFixture.rememberProjectId(projectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Browse owns Enter without opening the active typed path", async ({
|
||||||
|
page,
|
||||||
|
projectPickerFixture,
|
||||||
|
}) => {
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId: getServerId(),
|
||||||
|
manageBuiltInDaemon: false,
|
||||||
|
dialogOpenResult: null,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
|
||||||
|
await page.getByTestId("sidebar-add-project").click();
|
||||||
|
const input = page.getByTestId("project-picker-input");
|
||||||
|
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||||
|
await input.fill(projectPickerFixture.projectPath);
|
||||||
|
|
||||||
|
const browse = page.getByRole("button", { name: "Browse…" });
|
||||||
|
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||||
|
await browse.press("Enter");
|
||||||
|
|
||||||
|
const dialogOptions = await waitForDirectoryDialog(page);
|
||||||
|
expect(dialogOptions).toEqual({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
|
.filter({ hasText: projectPickerFixture.projectName }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -136,7 +136,7 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<
|
|||||||
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
|
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
|
||||||
await page.getByTestId("sidebar-add-project").click();
|
await page.getByTestId("sidebar-add-project").click();
|
||||||
|
|
||||||
const input = page.getByPlaceholder("Type a directory path...");
|
const input = page.getByTestId("project-picker-input");
|
||||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||||
await input.fill(projectPath);
|
await input.fill(projectPath);
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
|
import { expectWorkspaceAbsentFromSidebar, selectWorkspaceInSidebar } from "./helpers/sidebar";
|
||||||
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
|
test.describe("Workspace archive shortcut", () => {
|
||||||
|
test("archives the selected workspace without removing its local checkout", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "archive-shortcut-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||||
|
|
||||||
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
|
await page.keyboard.press(`${modifier}+Shift+Backspace`);
|
||||||
|
|
||||||
|
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
|
||||||
|
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,7 +76,7 @@ async function clickArchiveAndAnswerWarning(
|
|||||||
return warning;
|
return warning;
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Worktree archive risk warning", () => {
|
test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
const createdWorktreeDirectories = new Set<string>();
|
const createdWorktreeDirectories = new Set<string>();
|
||||||
@@ -97,7 +97,7 @@ test.describe("Worktree archive risk warning", () => {
|
|||||||
await tempRepo?.cleanup().catch(() => undefined);
|
await tempRepo?.cleanup().catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
|
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
openProjectViaDaemon,
|
openProjectViaDaemon,
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
|
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
test.describe("Worktree archive", () => {
|
test.describe("Workspace archive with worktree backing", () => {
|
||||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
const createdWorktreeDirectories = new Set<string>();
|
const createdWorktreeDirectories = new Set<string>();
|
||||||
@@ -33,9 +33,7 @@ test.describe("Worktree archive", () => {
|
|||||||
await tempRepo?.cleanup().catch(() => undefined);
|
await tempRepo?.cleanup().catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
|
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
await openProjectViaDaemon(client, tempRepo.path);
|
await openProjectViaDaemon(client, tempRepo.path);
|
||||||
const worktree = await createWorktreeViaDaemon(client, {
|
const worktree = await createWorktreeViaDaemon(client, {
|
||||||
@@ -49,11 +47,51 @@ test.describe("Worktree archive", () => {
|
|||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||||
|
|
||||||
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
|
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
|
||||||
|
|
||||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||||
await expect
|
await expect
|
||||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||||
.toBe(false);
|
.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
||||||
|
const serverId = getServerId();
|
||||||
|
await openProjectViaDaemon(client, tempRepo.path);
|
||||||
|
const first = await createWorktreeViaDaemon(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
slug: `shared-archive-${Date.now()}`,
|
||||||
|
});
|
||||||
|
createdWorktreeDirectories.add(first.workspaceDirectory);
|
||||||
|
|
||||||
|
const siblingPayload = await client.createWorkspace({
|
||||||
|
source: {
|
||||||
|
kind: "directory",
|
||||||
|
path: first.workspaceDirectory,
|
||||||
|
},
|
||||||
|
title: "Second workspace",
|
||||||
|
});
|
||||||
|
if (!siblingPayload.workspace) {
|
||||||
|
throw new Error(siblingPayload.error ?? "Failed to create a workspace on the worktree");
|
||||||
|
}
|
||||||
|
const sibling = siblingPayload.workspace;
|
||||||
|
expect(sibling.workspaceKind).toBe("worktree");
|
||||||
|
expect(sibling.workspaceDirectory).toBe(first.workspaceDirectory);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: first.workspaceId });
|
||||||
|
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||||
|
|
||||||
|
await archiveWorkspaceFromSidebar(page, first.workspaceId);
|
||||||
|
|
||||||
|
await expectWorkspaceAbsentFromSidebar(page, first.workspaceId);
|
||||||
|
expect(existsSync(first.workspaceDirectory)).toBe(true);
|
||||||
|
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||||
|
|
||||||
|
await archiveWorkspaceFromSidebar(page, sibling.id);
|
||||||
|
|
||||||
|
await expectWorkspaceAbsentFromSidebar(page, sibling.id);
|
||||||
|
await expect.poll(() => existsSync(first.workspaceDirectory), { timeout: 30_000 }).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.104",
|
"version": "0.1.106",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:browser": "vitest run --project browser",
|
"test:browser": "vitest run --project browser",
|
||||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
"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:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"build": "npm run build:web",
|
"build": "npm run build:web",
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: segments.historyVirtualized.length,
|
count: segments.historyVirtualized.length,
|
||||||
|
enabled: shouldUseVirtualizer,
|
||||||
getScrollElement: () => scrollContainerRef.current,
|
getScrollElement: () => scrollContainerRef.current,
|
||||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||||
estimateSize: (index: number) => {
|
estimateSize: (index: number) => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "@/components/message";
|
} from "@/components/message";
|
||||||
import type { TurnFooterHost } from "./layout";
|
import type { TurnFooterHost } from "./layout";
|
||||||
import { SyncedLoader } from "@/components/synced-loader";
|
import { SyncedLoader } from "@/components/synced-loader";
|
||||||
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
|
|
||||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||||
const workingIndicatorColorMapping = (theme: Theme) => ({
|
const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||||
@@ -98,6 +99,7 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
|||||||
}: {
|
}: {
|
||||||
inFlightTurnStartedAt?: Date | null;
|
inFlightTurnStartedAt?: Date | null;
|
||||||
}) {
|
}) {
|
||||||
|
const active = useRetainedPanelActive();
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.turnFooterContent}>
|
<View style={stylesheet.turnFooterContent}>
|
||||||
<View style={stylesheet.workingLoader}>
|
<View style={stylesheet.workingLoader}>
|
||||||
@@ -106,6 +108,7 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
|||||||
{inFlightTurnStartedAt ? (
|
{inFlightTurnStartedAt ? (
|
||||||
<LiveElapsed
|
<LiveElapsed
|
||||||
startedAt={inFlightTurnStartedAt}
|
startedAt={inFlightTurnStartedAt}
|
||||||
|
active={active}
|
||||||
style={stylesheet.workingElapsed}
|
style={stylesheet.workingElapsed}
|
||||||
testID="turn-working-elapsed"
|
testID="turn-working-elapsed"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -88,7 +87,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
|||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||||
import { MountedTabActiveContext } from "@/components/split-container";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
import {
|
import {
|
||||||
buildDraftWorkspaceAttachmentScopeKey,
|
buildDraftWorkspaceAttachmentScopeKey,
|
||||||
@@ -509,7 +508,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
// cell-window renders on every 48ms flush from background agents.
|
// cell-window renders on every 48ms flush from background agents.
|
||||||
// When isActive flips back to true, the context change triggers a re-render and
|
// When isActive flips back to true, the context change triggers a re-render and
|
||||||
// the component reads the current (fresh) streamItems/streamHead from props.
|
// the component reads the current (fresh) streamItems/streamHead from props.
|
||||||
const isActive = useContext(MountedTabActiveContext);
|
const isActive = useRetainedPanelActive();
|
||||||
const frozenStreamItemsRef = useRef(streamItems);
|
const frozenStreamItemsRef = useRef(streamItems);
|
||||||
const frozenStreamHeadRef = useRef(streamHead);
|
const frozenStreamHeadRef = useRef(streamHead);
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
|
|||||||
@@ -4,13 +4,7 @@ import { PortalProvider } from "@gorhom/portal";
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import {
|
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
|
||||||
Stack,
|
|
||||||
useGlobalSearchParams,
|
|
||||||
useNavigationContainerRef,
|
|
||||||
usePathname,
|
|
||||||
useRouter,
|
|
||||||
} from "expo-router";
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -23,9 +17,8 @@ import {
|
|||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
@@ -34,6 +27,7 @@ import { DownloadToast } from "@/components/download-toast";
|
|||||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
import { LeftSidebar } from "@/components/left-sidebar";
|
import { LeftSidebar } from "@/components/left-sidebar";
|
||||||
|
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||||
@@ -44,16 +38,8 @@ import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
|||||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import {
|
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
|
||||||
HorizontalScrollProvider,
|
|
||||||
useHorizontalScrollOptional,
|
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
|
||||||
import {
|
|
||||||
SidebarAnimationProvider,
|
|
||||||
useSidebarAnimation,
|
|
||||||
} from "@/contexts/sidebar-animation-context";
|
|
||||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||||
import { ToastProvider } from "@/contexts/toast-context";
|
import { ToastProvider } from "@/contexts/toast-context";
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
@@ -66,6 +52,7 @@ import {
|
|||||||
type StartupBlocker,
|
type StartupBlocker,
|
||||||
} from "@/navigation/host-runtime-bootstrap";
|
} from "@/navigation/host-runtime-bootstrap";
|
||||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||||
|
import { ThemedStack } from "@/navigation/themed-stack";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
@@ -82,6 +69,8 @@ import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-
|
|||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
|
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
||||||
|
import { MobilePanelsProvider } from "@/mobile-panels/provider";
|
||||||
import { I18nProvider } from "@/i18n/provider";
|
import { I18nProvider } from "@/i18n/provider";
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||||
@@ -96,17 +85,11 @@ import {
|
|||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||||
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||||
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||||
import {
|
|
||||||
buildOpenProjectRoute,
|
|
||||||
parseHostAgentRouteFromPathname,
|
|
||||||
parseServerIdFromPathname,
|
|
||||||
parseWorkspaceOpenIntent,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
import {
|
import {
|
||||||
@@ -405,22 +388,16 @@ function QueryProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
||||||
const flexStyle = { flex: 1 } as const;
|
const flexStyle = { flex: 1 } as const;
|
||||||
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
|
|
||||||
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
||||||
|
|
||||||
interface AppContainerProps {
|
interface AppContainerProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
selectedAgentId?: string;
|
|
||||||
chromeEnabled?: boolean;
|
chromeEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
||||||
|
|
||||||
function AppContainer({
|
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
|
||||||
children,
|
|
||||||
selectedAgentId,
|
|
||||||
chromeEnabled: chromeEnabledOverride,
|
|
||||||
}: AppContainerProps) {
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||||
@@ -475,28 +452,31 @@ function AppContainer({
|
|||||||
useActiveWorktreeNewAction();
|
useActiveWorktreeNewAction();
|
||||||
useGlobalNewWorkspaceAction();
|
useGlobalNewWorkspaceAction();
|
||||||
|
|
||||||
|
const sidebarChrome = (
|
||||||
|
<SidebarChrome
|
||||||
|
showSidebar={chromeEnabled && (isCompactLayout || !isFocusModeEnabled)}
|
||||||
|
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const workspaceChrome = (
|
const workspaceChrome = (
|
||||||
<View style={rowStyle}>
|
<View style={rowStyle}>
|
||||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
{!isCompactLayout ? sidebarChrome : null}
|
||||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
|
||||||
)}
|
|
||||||
{isCompactLayout && chromeEnabled ? (
|
{isCompactLayout && chromeEnabled ? (
|
||||||
<ExplorerSidebarAnimationProvider>
|
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
<View style={flexStyle}>{children}</View>
|
||||||
<View style={flexStyle}>{children}</View>
|
</CompactExplorerSidebarHost>
|
||||||
</CompactExplorerSidebarHost>
|
|
||||||
</ExplorerSidebarAnimationProvider>
|
|
||||||
) : (
|
) : (
|
||||||
<View style={flexStyle}>{children}</View>
|
<View style={flexStyle}>{children}</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = (
|
const surface = (
|
||||||
<View style={layoutStyles.surfaceFill}>
|
<View style={layoutStyles.surfaceFill}>
|
||||||
{workspaceChrome}
|
{workspaceChrome}
|
||||||
<FloatingPanelPortalHost />
|
<FloatingPanelPortalHost />
|
||||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
{isCompactLayout ? sidebarChrome : null}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
<RosettaCalloutSource />
|
<RosettaCalloutSource />
|
||||||
<UpdateCalloutSource />
|
<UpdateCalloutSource />
|
||||||
@@ -505,18 +485,38 @@ function AppContainer({
|
|||||||
<HostChooserModal />
|
<HostChooserModal />
|
||||||
<ProjectPickerModal />
|
<ProjectPickerModal />
|
||||||
<ProviderSettingsHost />
|
<ProviderSettingsHost />
|
||||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
|
||||||
<WorkspaceSetupDialog />
|
<WorkspaceSetupDialog />
|
||||||
<KeyboardShortcutsDialog />
|
<KeyboardShortcutsDialog />
|
||||||
<QuittingOverlay />
|
<QuittingOverlay />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isCompactLayout) {
|
const content = isCompactLayout ? (
|
||||||
return content;
|
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
|
||||||
}
|
) : (
|
||||||
|
surface
|
||||||
|
);
|
||||||
|
|
||||||
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarChrome({
|
||||||
|
showSidebar,
|
||||||
|
keyboardShortcutsEnabled,
|
||||||
|
}: {
|
||||||
|
showSidebar: boolean;
|
||||||
|
keyboardShortcutsEnabled: boolean;
|
||||||
|
}) {
|
||||||
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
|
const isOpen = usePanelStore((state) =>
|
||||||
|
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<SidebarModelProvider active={showSidebar && isOpen}>
|
||||||
|
{showSidebar ? <LeftSidebar /> : null}
|
||||||
|
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||||
|
</SidebarModelProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileGestureWrapper({
|
function MobileGestureWrapper({
|
||||||
@@ -526,131 +526,13 @@ function MobileGestureWrapper({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
chromeEnabled: boolean;
|
chromeEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
const openGesture = useOpenAgentListGesture(chromeEnabled);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
|
||||||
const {
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
setOverlayPeek,
|
|
||||||
isGesturing,
|
|
||||||
mobilePanelState,
|
|
||||||
gestureAnimatingRef,
|
|
||||||
openGestureRef,
|
|
||||||
} = useSidebarAnimation();
|
|
||||||
const touchStartX = useSharedValue(0);
|
|
||||||
const touchStartY = useSharedValue(0);
|
|
||||||
const openGestureEnabled = chromeEnabled;
|
|
||||||
|
|
||||||
const handleGestureOpen = useCallback(() => {
|
|
||||||
gestureAnimatingRef.current = true;
|
|
||||||
showMobileAgentList();
|
|
||||||
}, [showMobileAgentList, gestureAnimatingRef]);
|
|
||||||
|
|
||||||
const openGesture = useMemo(
|
|
||||||
() =>
|
|
||||||
Gesture.Pan()
|
|
||||||
.withRef(openGestureRef)
|
|
||||||
.enabled(openGestureEnabled)
|
|
||||||
.manualActivation(true)
|
|
||||||
.failOffsetY([-10, 10])
|
|
||||||
.onTouchesDown((event) => {
|
|
||||||
const touch = event.changedTouches[0];
|
|
||||||
if (touch) {
|
|
||||||
touchStartX.value = touch.absoluteX;
|
|
||||||
touchStartY.value = touch.absoluteY;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onTouchesMove((event, stateManager) => {
|
|
||||||
const touch = event.changedTouches[0];
|
|
||||||
if (!touch || event.numberOfTouches !== 1) return;
|
|
||||||
|
|
||||||
const deltaX = touch.absoluteX - touchStartX.value;
|
|
||||||
const deltaY = touch.absoluteY - touchStartY.value;
|
|
||||||
const absDeltaX = Math.abs(deltaX);
|
|
||||||
const absDeltaY = Math.abs(deltaY);
|
|
||||||
|
|
||||||
if (!canOpenLeftSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deltaX <= -10) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deltaX > 15 && absDeltaX > absDeltaY) {
|
|
||||||
stateManager.activate();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onStart(() => {
|
|
||||||
isGesturing.value = true;
|
|
||||||
// The overlay is display:none while closed; reveal it for the drag.
|
|
||||||
runOnJS(setOverlayPeek)(true);
|
|
||||||
})
|
|
||||||
.onUpdate((event) => {
|
|
||||||
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
|
||||||
translateX.value = newTranslateX;
|
|
||||||
backdropOpacity.value = interpolate(
|
|
||||||
newTranslateX,
|
|
||||||
[-windowWidth, 0],
|
|
||||||
[0, 1],
|
|
||||||
Extrapolation.CLAMP,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.onEnd((event) => {
|
|
||||||
isGesturing.value = false;
|
|
||||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
|
||||||
if (shouldOpen) {
|
|
||||||
animateToOpen();
|
|
||||||
runOnJS(handleGestureOpen)();
|
|
||||||
} else {
|
|
||||||
animateToClose();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onFinalize(() => {
|
|
||||||
isGesturing.value = false;
|
|
||||||
runOnJS(setOverlayPeek)(false);
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
openGestureEnabled,
|
|
||||||
windowWidth,
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
mobilePanelState,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
setOverlayPeek,
|
|
||||||
handleGestureOpen,
|
|
||||||
isGesturing,
|
|
||||||
openGestureRef,
|
|
||||||
horizontalScroll?.isAnyScrolledRight,
|
|
||||||
touchStartX,
|
|
||||||
touchStartY,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||||
{children}
|
<View collapsable={false} style={layoutStyles.surfaceFill}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -882,7 +764,6 @@ function OpenProjectListener() {
|
|||||||
|
|
||||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const storeReady = useStoreReady();
|
const storeReady = useStoreReady();
|
||||||
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
@@ -896,30 +777,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
pathname === "/schedules" ||
|
pathname === "/schedules" ||
|
||||||
routeHasKnownHost);
|
routeHasKnownHost);
|
||||||
|
|
||||||
// Parse selectedAgentKey directly from pathname
|
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
||||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
|
||||||
const selectedAgentKey = useMemo(() => {
|
|
||||||
const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/);
|
|
||||||
const workspaceServerId = workspaceMatch?.[1]?.trim() ?? "";
|
|
||||||
const openValue = Array.isArray(params.open) ? params.open[0] : params.open;
|
|
||||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
|
||||||
if (workspaceServerId && openIntent?.kind === "agent") {
|
|
||||||
const agentId = openIntent.agentId.trim();
|
|
||||||
return agentId ? `${workspaceServerId}:${agentId}` : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = parseHostAgentRouteFromPathname(pathname);
|
|
||||||
return match ? `${match.serverId}:${match.agentId}` : undefined;
|
|
||||||
}, [params.open, pathname]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppContainer
|
|
||||||
selectedAgentId={shouldShowAppChrome ? selectedAgentKey : undefined}
|
|
||||||
chromeEnabled={shouldShowAppChrome}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AppContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FaviconStatusSync() {
|
function FaviconStatusSync() {
|
||||||
@@ -927,21 +785,15 @@ function FaviconStatusSync() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROOT_STACK_SCREEN_OPTIONS = {
|
||||||
|
headerShown: false,
|
||||||
|
animation: "none" as const,
|
||||||
|
};
|
||||||
|
|
||||||
function RootStack() {
|
function RootStack() {
|
||||||
const storeReady = useStoreReady();
|
const storeReady = useStoreReady();
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const stackScreenOptions = useMemo(
|
|
||||||
() => ({
|
|
||||||
headerShown: false,
|
|
||||||
animation: "none" as const,
|
|
||||||
contentStyle: {
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[theme.colors.surface0],
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<Stack screenOptions={stackScreenOptions}>
|
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
<Stack.Protected guard={storeReady}>
|
<Stack.Protected guard={storeReady}>
|
||||||
<Stack.Screen name="welcome" />
|
<Stack.Screen name="welcome" />
|
||||||
@@ -958,7 +810,7 @@ function RootStack() {
|
|||||||
<Stack.Screen name="h/[serverId]" />
|
<Stack.Screen name="h/[serverId]" />
|
||||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||||
</Stack>
|
</ThemedStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,7 +826,7 @@ function WorkspaceRouteNavigationBridge() {
|
|||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
return (
|
return (
|
||||||
<SidebarAnimationProvider>
|
<MobilePanelsProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<OpenProjectListener />
|
<OpenProjectListener />
|
||||||
<AppWithSidebar>
|
<AppWithSidebar>
|
||||||
@@ -982,7 +834,7 @@ function AppShell() {
|
|||||||
<RootStack />
|
<RootStack />
|
||||||
</AppWithSidebar>
|
</AppWithSidebar>
|
||||||
</HorizontalScrollProvider>
|
</HorizontalScrollProvider>
|
||||||
</SidebarAnimationProvider>
|
</MobilePanelsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Redirect, Stack, useLocalSearchParams } from "expo-router";
|
|||||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||||
import { HostRouteProvider } from "@/navigation/host-route-context";
|
import { HostRouteProvider } from "@/navigation/host-route-context";
|
||||||
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
|
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
|
||||||
|
import { ThemedStack } from "@/navigation/themed-stack";
|
||||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||||
|
|
||||||
const HOST_STACK_SCREEN_OPTIONS = {
|
const HOST_STACK_SCREEN_OPTIONS = {
|
||||||
@@ -33,14 +34,14 @@ function KnownHostRoute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stack = (
|
const stack = (
|
||||||
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
<Stack.Screen name="workspace/[workspaceId]/index" />
|
<Stack.Screen name="workspace/[workspaceId]/index" />
|
||||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||||
<Stack.Screen name="sessions" />
|
<Stack.Screen name="sessions" />
|
||||||
<Stack.Screen name="open-project" />
|
<Stack.Screen name="open-project" />
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings" />
|
||||||
</Stack>
|
</ThemedStack>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!routeServerId) {
|
if (!routeServerId) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
|
import { RetainedPanel } from "@/components/retained-panel";
|
||||||
import {
|
import {
|
||||||
type ActiveWorkspaceSelection,
|
type ActiveWorkspaceSelection,
|
||||||
useActiveWorkspaceSelection,
|
useActiveWorkspaceSelection,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
areWorkspaceSelectionListsEqual,
|
areWorkspaceSelectionListsEqual,
|
||||||
areWorkspaceSelectionsEqual,
|
areWorkspaceSelectionsEqual,
|
||||||
getWorkspaceSelectionKey,
|
getWorkspaceSelectionKey,
|
||||||
|
orderWorkspaceSelectionsForStableRender,
|
||||||
pruneMountedWorkspaceSelections,
|
pruneMountedWorkspaceSelections,
|
||||||
shouldKeepWorkspaceDeckEntryMounted,
|
shouldKeepWorkspaceDeckEntryMounted,
|
||||||
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||||
@@ -185,22 +187,25 @@ function WorkspaceDeck() {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const nextMountedSelections = useMemo(
|
||||||
if (!activeSelection) {
|
() =>
|
||||||
return;
|
pruneMountedWorkspaceSelections({
|
||||||
}
|
currentSelections: mountedSelections,
|
||||||
setMountedSelections((current) => {
|
|
||||||
const next = pruneMountedWorkspaceSelections({
|
|
||||||
currentSelections: current,
|
|
||||||
activeSelection,
|
activeSelection,
|
||||||
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||||
});
|
}),
|
||||||
if (areWorkspaceSelectionListsEqual(current, next)) {
|
[activeSelection, mountedSelections],
|
||||||
return current;
|
);
|
||||||
}
|
const renderedSelections = useMemo(
|
||||||
return next;
|
() => orderWorkspaceSelectionsForStableRender(nextMountedSelections),
|
||||||
});
|
[nextMountedSelections],
|
||||||
}, [activeSelection]);
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!areWorkspaceSelectionListsEqual(mountedSelections, nextMountedSelections)) {
|
||||||
|
setMountedSelections(nextMountedSelections);
|
||||||
|
}
|
||||||
|
}, [mountedSelections, nextMountedSelections]);
|
||||||
|
|
||||||
if (!activeSelection) {
|
if (!activeSelection) {
|
||||||
return null;
|
return null;
|
||||||
@@ -208,7 +213,7 @@ function WorkspaceDeck() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.deck}>
|
<View style={styles.deck}>
|
||||||
{mountedSelections.map((selection) => {
|
{renderedSelections.map((selection) => {
|
||||||
return (
|
return (
|
||||||
<WorkspaceDeckEntry
|
<WorkspaceDeckEntry
|
||||||
key={getWorkspaceSelectionKey(selection)}
|
key={getWorkspaceSelectionKey(selection)}
|
||||||
@@ -251,8 +256,8 @@ function WorkspaceDeckEntry({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<RetainedPanel
|
||||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
active={isActive}
|
||||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||||
>
|
>
|
||||||
<WorkspaceScreen
|
<WorkspaceScreen
|
||||||
@@ -260,7 +265,7 @@ function WorkspaceDeckEntry({
|
|||||||
workspaceId={selection.workspaceId}
|
workspaceId={selection.workspaceId}
|
||||||
isRouteFocused={isActive}
|
isRouteFocused={isActive}
|
||||||
/>
|
/>
|
||||||
</View>
|
</RetainedPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,11 +273,4 @@ const styles = StyleSheet.create({
|
|||||||
deck: {
|
deck: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
activeDeckEntry: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
inactiveDeckEntry: {
|
|
||||||
display: "none",
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { GestureDetector } from "react-native-gesture-handler";
|
|||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||||
import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
|
import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
@@ -29,7 +29,7 @@ function CompactExplorerOpenGestureSurface({
|
|||||||
enabled,
|
enabled,
|
||||||
onOpenExplorer,
|
onOpenExplorer,
|
||||||
}: CompactExplorerOpenGestureSurfaceProps) {
|
}: CompactExplorerOpenGestureSurfaceProps) {
|
||||||
const explorerOpenGesture = useExplorerOpenGesture({
|
const explorerOpenGesture = useOpenFileExplorerGesture({
|
||||||
enabled,
|
enabled,
|
||||||
onOpen: onOpenExplorer,
|
onOpen: onOpenExplorer,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||||
import { useExplorerSidebarAnimationOptional } from "@/contexts/explorer-sidebar-animation-context";
|
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
||||||
|
|
||||||
interface DiffScrollProps {
|
interface DiffScrollProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -30,9 +30,7 @@ export function DiffScroll({
|
|||||||
const scrollId = useId();
|
const scrollId = useId();
|
||||||
const scrollViewRef = useRef<ScrollViewType>(null);
|
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||||
|
|
||||||
// Get the close gesture ref from animation context (may not be available outside sidebar)
|
const closeGestureRef = useFileExplorerCloseGestureRef();
|
||||||
const animation = useExplorerSidebarAnimationOptional();
|
|
||||||
const closeGestureRef = animation?.closeGestureRef;
|
|
||||||
|
|
||||||
// Register/unregister scroll offset tracking
|
// Register/unregister scroll offset tracking
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -29,16 +29,16 @@ import {
|
|||||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||||
|
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||||
import { GitDiffPane } from "@/git/diff-pane";
|
import { GitDiffPane } from "@/git/diff-pane";
|
||||||
import { FileExplorerPane } from "./file-explorer-pane";
|
import { FileExplorerPane } from "./file-explorer-pane";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
|
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||||
|
|
||||||
@@ -91,25 +91,11 @@ export function CompactExplorerSidebar({
|
|||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
isGit,
|
isGit,
|
||||||
});
|
});
|
||||||
const closeTouchStartX = useSharedValue(0);
|
|
||||||
const closeTouchStartY = useSharedValue(0);
|
|
||||||
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
|
||||||
useSidebarAnimation();
|
|
||||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||||
mode: "padding",
|
mode: "padding",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
const {
|
const { gesture: closeGesture } = useCloseFileExplorerGesture();
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
overlayVisible,
|
|
||||||
isGesturing,
|
|
||||||
gestureAnimatingRef,
|
|
||||||
closeGestureRef,
|
|
||||||
} = useExplorerSidebarAnimation();
|
|
||||||
|
|
||||||
const handleClose = useCallback(
|
const handleClose = useCallback(
|
||||||
(reason: string) => {
|
(reason: string) => {
|
||||||
@@ -122,184 +108,40 @@ export function CompactExplorerSidebar({
|
|||||||
[isOpen, showMobileAgent],
|
[isOpen, showMobileAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseFromGesture = useCallback(() => {
|
|
||||||
gestureAnimatingRef.current = true;
|
|
||||||
mobilePanelGestureAnimatingRef.current = true;
|
|
||||||
showMobileAgent();
|
|
||||||
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
|
|
||||||
|
|
||||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||||
|
|
||||||
// Swipe gesture to close (swipe right on mobile)
|
|
||||||
const closeGesture = useMemo(
|
|
||||||
() =>
|
|
||||||
Gesture.Pan()
|
|
||||||
.withRef(closeGestureRef)
|
|
||||||
.enabled(true)
|
|
||||||
// Use manual activation so child views keep touch streams
|
|
||||||
// unless we detect an intentional right-swipe close.
|
|
||||||
.manualActivation(true)
|
|
||||||
.onTouchesDown((event) => {
|
|
||||||
const touch = event.changedTouches[0];
|
|
||||||
if (!touch) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeTouchStartX.value = touch.absoluteX;
|
|
||||||
closeTouchStartY.value = touch.absoluteY;
|
|
||||||
})
|
|
||||||
.onTouchesMove((event, stateManager) => {
|
|
||||||
const touch = event.changedTouches[0];
|
|
||||||
if (!touch || event.numberOfTouches !== 1) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaX = touch.absoluteX - closeTouchStartX.value;
|
|
||||||
const deltaY = touch.absoluteY - closeTouchStartY.value;
|
|
||||||
const absDeltaX = Math.abs(deltaX);
|
|
||||||
const absDeltaY = Math.abs(deltaY);
|
|
||||||
|
|
||||||
if (!canCloseRightSidebarGesture(mobilePanelState.value)) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fail quickly on clear leftward or vertical intent so child views keep control.
|
|
||||||
if (deltaX <= -10) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activate only on intentional rightward movement.
|
|
||||||
if (deltaX >= 15 && absDeltaX > absDeltaY) {
|
|
||||||
stateManager.activate();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onStart(() => {
|
|
||||||
isGesturing.value = true;
|
|
||||||
})
|
|
||||||
.onUpdate((event) => {
|
|
||||||
// Right sidebar: swipe right to close (positive translationX)
|
|
||||||
const newTranslateX = Math.max(0, Math.min(windowWidth, event.translationX));
|
|
||||||
translateX.value = newTranslateX;
|
|
||||||
const progress = 1 - newTranslateX / windowWidth;
|
|
||||||
backdropOpacity.value = Math.max(0, Math.min(1, progress));
|
|
||||||
})
|
|
||||||
.onEnd((event) => {
|
|
||||||
isGesturing.value = false;
|
|
||||||
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
|
||||||
runOnJS(logExplorerSidebar)("closeGestureEnd", {
|
|
||||||
translationX: event.translationX,
|
|
||||||
velocityX: event.velocityX,
|
|
||||||
shouldClose,
|
|
||||||
windowWidth,
|
|
||||||
});
|
|
||||||
if (shouldClose) {
|
|
||||||
animateToClose();
|
|
||||||
runOnJS(handleCloseFromGesture)();
|
|
||||||
} else {
|
|
||||||
animateToOpen();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onFinalize(() => {
|
|
||||||
isGesturing.value = false;
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
windowWidth,
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
mobilePanelState,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
handleCloseFromGesture,
|
|
||||||
isGesturing,
|
|
||||||
closeGestureRef,
|
|
||||||
closeTouchStartX,
|
|
||||||
closeTouchStartY,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ translateX: translateX.value }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: backdropOpacity.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const backdropCombinedStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
explorerStaticStyles.backdrop,
|
|
||||||
backdropAnimatedStyle,
|
|
||||||
// pointerEvents is React-owned, not worklet-owned: Reanimated never
|
|
||||||
// touches it, so a stale animated-prop revert can't wedge an invisible
|
|
||||||
// tap-eating backdrop.
|
|
||||||
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
|
|
||||||
],
|
|
||||||
[backdropAnimatedStyle, isOpen],
|
|
||||||
);
|
|
||||||
const mobileSidebarStyle = useMemo(
|
const mobileSidebarStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
explorerStaticStyles.mobileSidebar,
|
|
||||||
{
|
{
|
||||||
width: windowWidth,
|
|
||||||
paddingTop: insets.top,
|
paddingTop: insets.top,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
},
|
},
|
||||||
sidebarAnimatedStyle,
|
|
||||||
mobileKeyboardInsetStyle,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
windowWidth,
|
|
||||||
insets.top,
|
|
||||||
theme.colors.surfaceSidebar,
|
|
||||||
sidebarAnimatedStyle,
|
|
||||||
mobileKeyboardInsetStyle,
|
mobileKeyboardInsetStyle,
|
||||||
],
|
],
|
||||||
|
[insets.top, theme.colors.surfaceSidebar, mobileKeyboardInsetStyle],
|
||||||
);
|
);
|
||||||
// display is React-owned on the plain wrapper View (no animated styles), so
|
|
||||||
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
|
|
||||||
// reverts the panel transform to after a heavy commit (reanimated#9635).
|
|
||||||
const overlayStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
StyleSheet.absoluteFillObject,
|
|
||||||
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
|
|
||||||
],
|
|
||||||
[overlayVisible],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mobile: full-screen overlay with gesture.
|
|
||||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
|
||||||
let overlayPointerEvents: "auto" | "none" | "box-none";
|
|
||||||
if (!isWeb) overlayPointerEvents = "box-none";
|
|
||||||
else if (isOpen) overlayPointerEvents = "auto";
|
|
||||||
else overlayPointerEvents = "none";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
<RetainedPanelActivity active={isOpen}>
|
||||||
<Animated.View style={backdropCombinedStyle} />
|
<MobilePanelOverlay
|
||||||
|
panel="file-explorer"
|
||||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
closeGesture={closeGesture}
|
||||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
panelStyle={mobileSidebarStyle}
|
||||||
<ExplorerSidebarContent
|
>
|
||||||
activeTab={explorerTab}
|
<ExplorerSidebarContent
|
||||||
onTabPress={handleTabPress}
|
activeTab={explorerTab}
|
||||||
onClose={handleHeaderClose}
|
onTabPress={handleTabPress}
|
||||||
serverId={serverId}
|
onClose={handleHeaderClose}
|
||||||
workspaceId={workspaceId}
|
serverId={serverId}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceId={workspaceId}
|
||||||
isGit={isGit}
|
workspaceRoot={workspaceRoot}
|
||||||
isMobile
|
isGit={isGit}
|
||||||
isOpen={isOpen}
|
isMobile
|
||||||
onOpenFile={onOpenFile}
|
isOpen={isOpen}
|
||||||
/>
|
onOpenFile={onOpenFile}
|
||||||
</Animated.View>
|
/>
|
||||||
</GestureDetector>
|
</MobilePanelOverlay>
|
||||||
</View>
|
</RetainedPanelActivity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,17 +446,6 @@ function PrTabContent({
|
|||||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||||
// tries to patch the native node that Reanimated also manages.
|
// tries to patch the native node that Reanimated also manages.
|
||||||
const explorerStaticStyles = RNStyleSheet.create({
|
const explorerStaticStyles = RNStyleSheet.create({
|
||||||
backdrop: {
|
|
||||||
...RNStyleSheet.absoluteFillObject,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
||||||
},
|
|
||||||
mobileSidebar: {
|
|
||||||
position: "absolute" as const,
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
overflow: "hidden" as const,
|
|
||||||
},
|
|
||||||
desktopSidebar: {
|
desktopSidebar: {
|
||||||
position: "relative" as const,
|
position: "relative" as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useContext, useEffect, useMemo, useRef } from "react";
|
import React, { useEffect, useMemo, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||||
import {
|
import {
|
||||||
@@ -29,7 +29,7 @@ import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/ut
|
|||||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
import { MountedTabActiveContext } from "@/components/split-container";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||||
|
|
||||||
@@ -414,7 +414,7 @@ export function FilePane({
|
|||||||
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
||||||
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
|
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
|
||||||
// gate itself lives in isFileQueryEnabled.
|
// gate itself lives in isFileQueryEnabled.
|
||||||
const isActive = useContext(MountedTabActiveContext);
|
const isActive = useRetainedPanelActive();
|
||||||
const isAppVisible = useAppVisible();
|
const isAppVisible = useAppVisible();
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
|
|||||||
@@ -21,13 +21,7 @@ import {
|
|||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated, {
|
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||||
Extrapolation,
|
|
||||||
interpolate,
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
@@ -38,19 +32,18 @@ import { Shortcut } from "@/components/ui/shortcut";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
|
||||||
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||||
import { useHostFeature } from "@/runtime/host-features";
|
import { useHostFeature } from "@/runtime/host-features";
|
||||||
import {
|
import {
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarStatusWorkspacePlacement,
|
type SidebarWorkspaceEntry,
|
||||||
useSidebarWorkspacesList,
|
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||||
|
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||||
|
import { type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
@@ -62,7 +55,8 @@ import {
|
|||||||
usePanelStore,
|
usePanelStore,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||||
|
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||||
import {
|
import {
|
||||||
buildOpenProjectRoute,
|
buildOpenProjectRoute,
|
||||||
buildNewWorkspaceRoute,
|
buildNewWorkspaceRoute,
|
||||||
@@ -79,25 +73,21 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
|||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
|
|
||||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||||
|
|
||||||
interface LeftSidebarProps {
|
|
||||||
selectedAgentId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarSharedProps {
|
interface SidebarSharedProps {
|
||||||
theme: SidebarTheme;
|
theme: SidebarTheme;
|
||||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
statusGroups: StatusGroup[];
|
||||||
projects: SidebarProjectEntry[];
|
projects: SidebarProjectEntry[];
|
||||||
|
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
isInitialLoad: boolean;
|
isInitialLoad: boolean;
|
||||||
isRevalidating: boolean;
|
isRevalidating: boolean;
|
||||||
isManualRefresh: boolean;
|
isManualRefresh: boolean;
|
||||||
groupMode: SidebarGroupMode;
|
groupMode: SidebarGroupMode;
|
||||||
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
collapsedProjectKeys: ReadonlySet<string>;
|
||||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
toggleProjectCollapsed: (projectKey: string) => void;
|
||||||
handleRefresh: () => void;
|
handleRefresh: () => void;
|
||||||
handleOpenProject: () => void;
|
handleOpenProject: () => void;
|
||||||
handleHome: () => void;
|
handleHome: () => void;
|
||||||
@@ -123,7 +113,6 @@ interface SidebarLabels {
|
|||||||
interface MobileSidebarProps extends SidebarSharedProps {
|
interface MobileSidebarProps extends SidebarSharedProps {
|
||||||
insetsTop: number;
|
insetsTop: number;
|
||||||
insetsBottom: number;
|
insetsBottom: number;
|
||||||
isOpen: boolean;
|
|
||||||
closeSidebar: () => void;
|
closeSidebar: () => void;
|
||||||
handleViewMoreNavigate: () => void;
|
handleViewMoreNavigate: () => void;
|
||||||
handleViewSchedulesNavigate: () => void;
|
handleViewSchedulesNavigate: () => void;
|
||||||
@@ -136,11 +125,7 @@ interface DesktopSidebarProps extends SidebarSharedProps {
|
|||||||
handleViewSchedules: () => void;
|
handleViewSchedules: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LeftSidebar = memo(function LeftSidebar({
|
export const LeftSidebar = memo(function LeftSidebar() {
|
||||||
selectedAgentId: _selectedAgentId,
|
|
||||||
}: LeftSidebarProps) {
|
|
||||||
void _selectedAgentId;
|
|
||||||
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -151,22 +136,19 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
workspacePlacements,
|
|
||||||
projects,
|
projects,
|
||||||
|
workspaceEntriesByKey,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
} = useSidebarWorkspacesList({
|
statusGroups,
|
||||||
enabled: isCompactLayout || isOpen,
|
collapsedProjectKeys,
|
||||||
});
|
toggleProjectCollapsed,
|
||||||
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
groupMode,
|
||||||
placements: workspacePlacements,
|
shortcutModel,
|
||||||
});
|
} = useSidebarModel();
|
||||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
const { shortcutIndexByWorkspaceKey } = shortcutModel;
|
||||||
useSidebarShortcutModel({ projects });
|
|
||||||
|
|
||||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
|
||||||
|
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
|
|
||||||
@@ -257,8 +239,9 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
|
|
||||||
const sharedProps = {
|
const sharedProps = {
|
||||||
theme,
|
theme,
|
||||||
statusWorkspacePlacements,
|
statusGroups,
|
||||||
projects,
|
projects,
|
||||||
|
workspaceEntriesByKey,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
@@ -274,36 +257,39 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
|
|
||||||
if (isCompactLayout) {
|
if (isCompactLayout) {
|
||||||
return (
|
return (
|
||||||
<MobileSidebar
|
<RetainedPanelActivity active={isOpen}>
|
||||||
{...sharedProps}
|
<MobileSidebar
|
||||||
insetsTop={insets.top}
|
{...sharedProps}
|
||||||
insetsBottom={insets.bottom}
|
insetsTop={insets.top}
|
||||||
isOpen={isOpen}
|
insetsBottom={insets.bottom}
|
||||||
closeSidebar={showMobileAgent}
|
closeSidebar={showMobileAgent}
|
||||||
handleOpenProject={handleOpenProjectMobile}
|
handleOpenProject={handleOpenProjectMobile}
|
||||||
handleHome={handleHomeMobile}
|
handleHome={handleHomeMobile}
|
||||||
handleSettings={handleSettingsMobile}
|
handleSettings={handleSettingsMobile}
|
||||||
handleAddHost={handleAddHostMobile}
|
handleAddHost={handleAddHostMobile}
|
||||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||||
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
||||||
/>
|
/>
|
||||||
|
</RetainedPanelActivity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DesktopSidebar
|
<RetainedPanelActivity active={isOpen}>
|
||||||
{...sharedProps}
|
<DesktopSidebar
|
||||||
insetsTop={insets.top}
|
{...sharedProps}
|
||||||
isOpen={isOpen}
|
insetsTop={insets.top}
|
||||||
handleOpenProject={handleOpenProjectDesktop}
|
isOpen={isOpen}
|
||||||
handleHome={handleHomeDesktop}
|
handleOpenProject={handleOpenProjectDesktop}
|
||||||
handleSettings={handleSettingsDesktop}
|
handleHome={handleHomeDesktop}
|
||||||
handleAddHost={handleAddHostDesktop}
|
handleSettings={handleSettingsDesktop}
|
||||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
handleAddHost={handleAddHostDesktop}
|
||||||
handleViewMore={handleViewMoreNavigate}
|
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||||
handleViewSchedules={handleViewSchedulesNavigate}
|
handleViewMore={handleViewMoreNavigate}
|
||||||
/>
|
handleViewSchedules={handleViewSchedulesNavigate}
|
||||||
|
/>
|
||||||
|
</RetainedPanelActivity>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -556,8 +542,9 @@ function SidebarFooter({
|
|||||||
|
|
||||||
function MobileSidebar({
|
function MobileSidebar({
|
||||||
theme,
|
theme,
|
||||||
statusWorkspacePlacements,
|
statusGroups,
|
||||||
projects,
|
projects,
|
||||||
|
workspaceEntriesByKey,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
@@ -576,7 +563,6 @@ function MobileSidebar({
|
|||||||
handleOpenHostSettings,
|
handleOpenHostSettings,
|
||||||
insetsTop,
|
insetsTop,
|
||||||
insetsBottom,
|
insetsBottom,
|
||||||
isOpen,
|
|
||||||
closeSidebar,
|
closeSidebar,
|
||||||
handleViewMoreNavigate,
|
handleViewMoreNavigate,
|
||||||
handleViewSchedulesNavigate,
|
handleViewSchedulesNavigate,
|
||||||
@@ -584,269 +570,121 @@ function MobileSidebar({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isSessionsActive = pathname.includes("/sessions");
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
const isSchedulesActive = pathname.includes("/schedules");
|
const isSchedulesActive = pathname.includes("/schedules");
|
||||||
const {
|
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
overlayVisible,
|
|
||||||
isGesturing,
|
|
||||||
mobilePanelState,
|
|
||||||
gestureAnimatingRef,
|
|
||||||
closeGestureRef,
|
|
||||||
} = useSidebarAnimation();
|
|
||||||
const closeTouchStartX = useSharedValue(0);
|
|
||||||
const closeTouchStartY = useSharedValue(0);
|
|
||||||
|
|
||||||
const handleCloseFromGesture = useCallback(() => {
|
|
||||||
gestureAnimatingRef.current = true;
|
|
||||||
closeSidebar();
|
|
||||||
}, [closeSidebar, gestureAnimatingRef]);
|
|
||||||
|
|
||||||
const handleViewMore = useCallback(() => {
|
const handleViewMore = useCallback(() => {
|
||||||
translateX.value = -windowWidth;
|
|
||||||
backdropOpacity.value = 0;
|
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
handleViewMoreNavigate();
|
handleViewMoreNavigate();
|
||||||
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
}, [closeSidebar, handleViewMoreNavigate]);
|
||||||
|
|
||||||
const handleViewSchedules = useCallback(() => {
|
const handleViewSchedules = useCallback(() => {
|
||||||
translateX.value = -windowWidth;
|
|
||||||
backdropOpacity.value = 0;
|
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
handleViewSchedulesNavigate();
|
handleViewSchedulesNavigate();
|
||||||
}, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]);
|
}, [closeSidebar, handleViewSchedulesNavigate]);
|
||||||
|
|
||||||
const handleWorkspacePress = useCallback(() => {
|
const handleWorkspacePress = useCallback(() => {
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
}, [closeSidebar]);
|
}, [closeSidebar]);
|
||||||
|
|
||||||
const closeGesture = useMemo(
|
|
||||||
() =>
|
|
||||||
Gesture.Pan()
|
|
||||||
.withRef(closeGestureRef)
|
|
||||||
.enabled(true)
|
|
||||||
.manualActivation(true)
|
|
||||||
.onTouchesDown((event) => {
|
|
||||||
const touch = event.changedTouches[0];
|
|
||||||
if (!touch) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeTouchStartX.value = touch.absoluteX;
|
|
||||||
closeTouchStartY.value = touch.absoluteY;
|
|
||||||
})
|
|
||||||
.onTouchesMove((event, stateManager) => {
|
|
||||||
const touch = event.changedTouches[0];
|
|
||||||
if (!touch || event.numberOfTouches !== 1) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaX = touch.absoluteX - closeTouchStartX.value;
|
|
||||||
const deltaY = touch.absoluteY - closeTouchStartY.value;
|
|
||||||
const absDeltaX = Math.abs(deltaX);
|
|
||||||
const absDeltaY = Math.abs(deltaY);
|
|
||||||
|
|
||||||
if (!canCloseLeftSidebarGesture(mobilePanelState.value)) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deltaX >= 10) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (deltaX <= -15 && absDeltaX > absDeltaY) {
|
|
||||||
stateManager.activate();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onStart(() => {
|
|
||||||
isGesturing.value = true;
|
|
||||||
})
|
|
||||||
.onUpdate((event) => {
|
|
||||||
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
|
|
||||||
translateX.value = newTranslateX;
|
|
||||||
backdropOpacity.value = interpolate(
|
|
||||||
newTranslateX,
|
|
||||||
[-windowWidth, 0],
|
|
||||||
[0, 1],
|
|
||||||
Extrapolation.CLAMP,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.onEnd((event) => {
|
|
||||||
isGesturing.value = false;
|
|
||||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
|
||||||
if (shouldClose) {
|
|
||||||
animateToClose();
|
|
||||||
runOnJS(handleCloseFromGesture)();
|
|
||||||
} else {
|
|
||||||
animateToOpen();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onFinalize(() => {
|
|
||||||
isGesturing.value = false;
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
closeGestureRef,
|
|
||||||
closeTouchStartX,
|
|
||||||
closeTouchStartY,
|
|
||||||
isGesturing,
|
|
||||||
mobilePanelState,
|
|
||||||
windowWidth,
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
animateToClose,
|
|
||||||
animateToOpen,
|
|
||||||
handleCloseFromGesture,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const mobileSidebarInsetStyle = useMemo(
|
const mobileSidebarInsetStyle = useMemo(
|
||||||
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
|
() => ({
|
||||||
[windowWidth, insetsTop, insetsBottom],
|
paddingTop: insetsTop,
|
||||||
);
|
paddingBottom: insetsBottom,
|
||||||
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
}),
|
||||||
transform: [{ translateX: translateX.value }],
|
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
|
||||||
}));
|
|
||||||
|
|
||||||
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: backdropOpacity.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let overlayPointerEvents: "auto" | "none" | "box-none";
|
|
||||||
if (!isWeb) overlayPointerEvents = "box-none";
|
|
||||||
else if (isOpen) overlayPointerEvents = "auto";
|
|
||||||
else overlayPointerEvents = "none";
|
|
||||||
|
|
||||||
const backdropStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
staticStyles.backdrop,
|
|
||||||
backdropAnimatedStyle,
|
|
||||||
// pointerEvents is React-owned, not worklet-owned: Reanimated never
|
|
||||||
// touches it, so a stale animated-prop revert can't wedge an invisible
|
|
||||||
// tap-eating backdrop.
|
|
||||||
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
|
|
||||||
],
|
|
||||||
[backdropAnimatedStyle, isOpen],
|
|
||||||
);
|
|
||||||
const mobileSidebarStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
staticStyles.mobileSidebar,
|
|
||||||
mobileSidebarInsetStyle,
|
|
||||||
sidebarAnimatedStyle,
|
|
||||||
{ backgroundColor: theme.colors.surfaceSidebar },
|
|
||||||
],
|
|
||||||
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
|
|
||||||
);
|
|
||||||
// display is React-owned on the plain wrapper View (no animated styles), so
|
|
||||||
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
|
|
||||||
// reverts the panel transform to after a heavy commit (reanimated#9635).
|
|
||||||
const overlayStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
StyleSheet.absoluteFillObject,
|
|
||||||
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
|
|
||||||
],
|
|
||||||
[overlayVisible],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
<MobilePanelOverlay
|
||||||
<Animated.View style={backdropStyle} />
|
panel="agent-list"
|
||||||
|
closeGesture={closeGesture}
|
||||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
panelStyle={mobileSidebarInsetStyle}
|
||||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
>
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
<View style={styles.sidebarHeaderGroup}>
|
<View style={styles.sidebarHeaderGroup}>
|
||||||
<SidebarNewWorkspaceHeaderRow
|
<SidebarNewWorkspaceHeaderRow
|
||||||
label={labels.newWorkspace}
|
label={labels.newWorkspace}
|
||||||
testID="sidebar-global-new-workspace"
|
testID="sidebar-global-new-workspace"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
shortcutKeys={newWorkspaceKeys}
|
shortcutKeys={newWorkspaceKeys}
|
||||||
onBeforeNavigate={closeSidebar}
|
onBeforeNavigate={closeSidebar}
|
||||||
/>
|
/>
|
||||||
<SidebarHeaderRow
|
<SidebarHeaderRow
|
||||||
icon={History}
|
icon={History}
|
||||||
label={labels.sessions}
|
label={labels.sessions}
|
||||||
onPress={handleViewMore}
|
onPress={handleViewMore}
|
||||||
isActive={isSessionsActive}
|
isActive={isSessionsActive}
|
||||||
testID="sidebar-sessions"
|
testID="sidebar-sessions"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
<SidebarHeaderRow
|
<SidebarHeaderRow
|
||||||
icon={CalendarClock}
|
icon={CalendarClock}
|
||||||
label={labels.schedules}
|
label={labels.schedules}
|
||||||
onPress={handleViewSchedules}
|
onPress={handleViewSchedules}
|
||||||
isActive={isSchedulesActive}
|
isActive={isSchedulesActive}
|
||||||
testID="sidebar-schedules"
|
testID="sidebar-schedules"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<WorkspacesSectionHeader />
|
<WorkspacesSectionHeader />
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.mobileCloseButton}
|
style={styles.mobileCloseButton}
|
||||||
onPress={closeSidebar}
|
onPress={closeSidebar}
|
||||||
testID="sidebar-close"
|
testID="sidebar-close"
|
||||||
nativeID="sidebar-close"
|
nativeID="sidebar-close"
|
||||||
accessible
|
accessible
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={labels.closeSidebar}
|
accessibilityLabel={labels.closeSidebar}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
{({ hovered, pressed }) => (
|
{({ hovered, pressed }) => (
|
||||||
<X
|
<X
|
||||||
size={theme.iconSize.md}
|
size={theme.iconSize.md}
|
||||||
color={
|
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{isInitialLoad ? (
|
|
||||||
<SidebarAgentListSkeleton />
|
|
||||||
) : (
|
|
||||||
<SidebarWorkspaceList
|
|
||||||
collapsedProjectKeys={collapsedProjectKeys}
|
|
||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
||||||
groupMode={groupMode}
|
|
||||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
|
||||||
projects={projects}
|
|
||||||
projectNamesByKey={projectNamesByKey}
|
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
onWorkspacePress={handleWorkspacePress}
|
|
||||||
onAddProject={handleOpenProject}
|
|
||||||
parentGestureRef={closeGestureRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SidebarFooter
|
|
||||||
theme={theme}
|
|
||||||
handleOpenProject={handleOpenProject}
|
|
||||||
handleHome={handleHome}
|
|
||||||
handleSettings={handleSettings}
|
|
||||||
labels={labels}
|
|
||||||
handleAddHost={handleAddHost}
|
|
||||||
handleOpenHostSettings={handleOpenHostSettings}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
)}
|
||||||
</Animated.View>
|
</Pressable>
|
||||||
</GestureDetector>
|
|
||||||
</View>
|
{isInitialLoad ? (
|
||||||
|
<SidebarAgentListSkeleton />
|
||||||
|
) : (
|
||||||
|
<SidebarWorkspaceList
|
||||||
|
collapsedProjectKeys={collapsedProjectKeys}
|
||||||
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||||
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
|
groupMode={groupMode}
|
||||||
|
statusGroups={statusGroups}
|
||||||
|
projects={projects}
|
||||||
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||||
|
projectNamesByKey={projectNamesByKey}
|
||||||
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
onWorkspacePress={handleWorkspacePress}
|
||||||
|
onAddProject={handleOpenProject}
|
||||||
|
parentGestureRef={closeGestureRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SidebarFooter
|
||||||
|
theme={theme}
|
||||||
|
handleOpenProject={handleOpenProject}
|
||||||
|
handleHome={handleHome}
|
||||||
|
handleSettings={handleSettings}
|
||||||
|
labels={labels}
|
||||||
|
handleAddHost={handleAddHost}
|
||||||
|
handleOpenHostSettings={handleOpenHostSettings}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</MobilePanelOverlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopSidebar({
|
function DesktopSidebar({
|
||||||
theme,
|
theme,
|
||||||
statusWorkspacePlacements,
|
statusGroups,
|
||||||
projects,
|
projects,
|
||||||
|
workspaceEntriesByKey,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
@@ -970,8 +808,9 @@ function DesktopSidebar({
|
|||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
groupMode={groupMode}
|
groupMode={groupMode}
|
||||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
statusGroups={statusGroups}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||||
projectNamesByKey={projectNamesByKey}
|
projectNamesByKey={projectNamesByKey}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
@@ -1059,17 +898,6 @@ function WorkspacesSectionHeader() {
|
|||||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||||
// tries to patch the native node that Reanimated also manages.
|
// tries to patch the native node that Reanimated also manages.
|
||||||
const staticStyles = RNStyleSheet.create({
|
const staticStyles = RNStyleSheet.create({
|
||||||
backdrop: {
|
|
||||||
...RNStyleSheet.absoluteFillObject,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
||||||
},
|
|
||||||
mobileSidebar: {
|
|
||||||
position: "absolute" as const,
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
bottom: 0,
|
|
||||||
overflow: "hidden" as const,
|
|
||||||
},
|
|
||||||
desktopSidebar: {
|
desktopSidebar: {
|
||||||
position: "relative" as const,
|
position: "relative" as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -692,33 +692,39 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
|
|
||||||
interface LiveElapsedProps {
|
interface LiveElapsedProps {
|
||||||
startedAt: Date;
|
startedAt: Date;
|
||||||
|
active?: boolean;
|
||||||
style?: StyleProp<TextStyle>;
|
style?: StyleProp<TextStyle>;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ticks every 100ms to render an elapsed duration. Isolated from parents so
|
* Ticks every second to render an elapsed duration. Isolated from parents so
|
||||||
* only this component re-renders on each tick.
|
* only this component re-renders on each tick.
|
||||||
*/
|
*/
|
||||||
export const LiveElapsed = memo(function LiveElapsed({
|
export const LiveElapsed = memo(function LiveElapsed({
|
||||||
startedAt,
|
startedAt,
|
||||||
|
active = true,
|
||||||
style,
|
style,
|
||||||
testID,
|
testID,
|
||||||
}: LiveElapsedProps) {
|
}: LiveElapsedProps) {
|
||||||
const startedAtMs = startedAt.getTime();
|
const startedAtMs = startedAt.getTime();
|
||||||
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs));
|
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs));
|
||||||
|
const visibleElapsedMs = active ? Math.max(0, Date.now() - startedAtMs) : elapsedMs;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||||||
const handle = setInterval(() => {
|
const handle = setInterval(() => {
|
||||||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||||||
}, 100);
|
}, 1000);
|
||||||
return () => clearInterval(handle);
|
return () => clearInterval(handle);
|
||||||
}, [startedAtMs]);
|
}, [active, startedAtMs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text style={style} testID={testID}>
|
<Text style={style} testID={testID}>
|
||||||
{formatDuration(elapsedMs)}
|
{formatDuration(visibleElapsedMs)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,22 +9,16 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
|
|||||||
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||||
|
|
||||||
function MarkdownInlineText({
|
function MarkdownInlineText({
|
||||||
textKey,
|
|
||||||
inheritedStyle,
|
inheritedStyle,
|
||||||
ruleStyle,
|
ruleStyle,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
textKey: string;
|
|
||||||
inheritedStyle: StyleProp<TextStyle>;
|
inheritedStyle: StyleProp<TextStyle>;
|
||||||
ruleStyle: StyleProp<TextStyle>;
|
ruleStyle: StyleProp<TextStyle>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const style = useMemo(() => [inheritedStyle, ruleStyle], [inheritedStyle, ruleStyle]);
|
const style = useMemo(() => [inheritedStyle, ruleStyle], [inheritedStyle, ruleStyle]);
|
||||||
return (
|
return <Text style={style}>{children}</Text>;
|
||||||
<Text key={textKey} style={style}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownListItemContent({
|
function MarkdownListItemContent({
|
||||||
@@ -39,12 +33,10 @@ function MarkdownListItemContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownParagraph({
|
function MarkdownParagraph({
|
||||||
textKey,
|
|
||||||
paragraphStyle,
|
paragraphStyle,
|
||||||
isLastChild,
|
isLastChild,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
textKey: string;
|
|
||||||
paragraphStyle: StyleProp<ViewStyle>;
|
paragraphStyle: StyleProp<ViewStyle>;
|
||||||
isLastChild: boolean;
|
isLastChild: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -53,11 +45,7 @@ function MarkdownParagraph({
|
|||||||
() => [paragraphStyle, isLastChild ? PARAGRAPH_LAST_CHILD : null],
|
() => [paragraphStyle, isLastChild ? PARAGRAPH_LAST_CHILD : null],
|
||||||
[paragraphStyle, isLastChild],
|
[paragraphStyle, isLastChild],
|
||||||
);
|
);
|
||||||
return (
|
return <View style={style}>{children}</View>;
|
||||||
<View key={textKey} style={style}>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPlanMarkdownRules() {
|
function createPlanMarkdownRules() {
|
||||||
@@ -69,11 +57,7 @@ function createPlanMarkdownRules() {
|
|||||||
styles: MarkdownRuleStyles,
|
styles: MarkdownRuleStyles,
|
||||||
inheritedStyles: TextStyle = {},
|
inheritedStyles: TextStyle = {},
|
||||||
) => (
|
) => (
|
||||||
<MarkdownInlineText
|
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.text}>
|
||||||
textKey={node.key}
|
|
||||||
inheritedStyle={inheritedStyles}
|
|
||||||
ruleStyle={styles.text}
|
|
||||||
>
|
|
||||||
{node.content}
|
{node.content}
|
||||||
</MarkdownInlineText>
|
</MarkdownInlineText>
|
||||||
),
|
),
|
||||||
@@ -85,7 +69,7 @@ function createPlanMarkdownRules() {
|
|||||||
inheritedStyles: TextStyle = {},
|
inheritedStyles: TextStyle = {},
|
||||||
) => (
|
) => (
|
||||||
<MarkdownInlineText
|
<MarkdownInlineText
|
||||||
textKey={node.key}
|
key={node.key}
|
||||||
inheritedStyle={inheritedStyles}
|
inheritedStyle={inheritedStyles}
|
||||||
ruleStyle={styles.textgroup}
|
ruleStyle={styles.textgroup}
|
||||||
>
|
>
|
||||||
@@ -100,7 +84,7 @@ function createPlanMarkdownRules() {
|
|||||||
inheritedStyles: TextStyle = {},
|
inheritedStyles: TextStyle = {},
|
||||||
) => (
|
) => (
|
||||||
<MarkdownInlineText
|
<MarkdownInlineText
|
||||||
textKey={node.key}
|
key={node.key}
|
||||||
inheritedStyle={inheritedStyles}
|
inheritedStyle={inheritedStyles}
|
||||||
ruleStyle={styles.code_block}
|
ruleStyle={styles.code_block}
|
||||||
>
|
>
|
||||||
@@ -114,11 +98,7 @@ function createPlanMarkdownRules() {
|
|||||||
styles: MarkdownRuleStyles,
|
styles: MarkdownRuleStyles,
|
||||||
inheritedStyles: TextStyle = {},
|
inheritedStyles: TextStyle = {},
|
||||||
) => (
|
) => (
|
||||||
<MarkdownInlineText
|
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.fence}>
|
||||||
textKey={node.key}
|
|
||||||
inheritedStyle={inheritedStyles}
|
|
||||||
ruleStyle={styles.fence}
|
|
||||||
>
|
|
||||||
{node.content}
|
{node.content}
|
||||||
</MarkdownInlineText>
|
</MarkdownInlineText>
|
||||||
),
|
),
|
||||||
@@ -130,7 +110,7 @@ function createPlanMarkdownRules() {
|
|||||||
inheritedStyles: TextStyle = {},
|
inheritedStyles: TextStyle = {},
|
||||||
) => (
|
) => (
|
||||||
<MarkdownInlineText
|
<MarkdownInlineText
|
||||||
textKey={node.key}
|
key={node.key}
|
||||||
inheritedStyle={inheritedStyles}
|
inheritedStyle={inheritedStyles}
|
||||||
ruleStyle={styles.code_inline}
|
ruleStyle={styles.code_inline}
|
||||||
>
|
>
|
||||||
@@ -183,7 +163,7 @@ function createPlanMarkdownRules() {
|
|||||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||||
return (
|
return (
|
||||||
<MarkdownParagraph
|
<MarkdownParagraph
|
||||||
textKey={node.key}
|
key={node.key}
|
||||||
paragraphStyle={styles.paragraph}
|
paragraphStyle={styles.paragraph}
|
||||||
isLastChild={isLastChild}
|
isLastChild={isLastChild}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FolderOpen } from "lucide-react-native";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { pickDirectory } from "@/desktop/pick-directory";
|
||||||
|
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||||
|
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
|
||||||
|
|
||||||
|
export function ProjectPickerBrowseButton({
|
||||||
|
serverId,
|
||||||
|
disabled,
|
||||||
|
onSelect,
|
||||||
|
onError,
|
||||||
|
}: ProjectPickerBrowseButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const path = await pickDirectory();
|
||||||
|
if (path) {
|
||||||
|
onSelect(path);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onError();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [onError, onSelect]);
|
||||||
|
|
||||||
|
if (!isLocalDaemon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={FolderOpen}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
|
{t("projectPicker.browse")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
|
||||||
|
|
||||||
|
export function ProjectPickerBrowseButton(_props: ProjectPickerBrowseButtonProps) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface ProjectPickerBrowseButtonProps {
|
||||||
|
serverId: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
onError: () => void;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { useProjectPickerStore } from "@/stores/project-picker-store";
|
|||||||
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
import { ProjectPickerBrowseButton } from "./project-picker-browse-button";
|
||||||
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
|
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
|
||||||
|
|
||||||
interface PathRowProps {
|
interface PathRowProps {
|
||||||
@@ -155,31 +156,36 @@ export function ProjectPickerModal() {
|
|||||||
const directorySuggestionsQuery = useQuery({
|
const directorySuggestionsQuery = useQuery({
|
||||||
queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery],
|
queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) return [];
|
if (!client) {
|
||||||
|
return { query: debouncedQuery, paths: [] };
|
||||||
|
}
|
||||||
const result = await client.getDirectorySuggestions({
|
const result = await client.getDirectorySuggestions({
|
||||||
query: debouncedQuery,
|
query: debouncedQuery,
|
||||||
includeDirectories: true,
|
includeDirectories: true,
|
||||||
includeFiles: false,
|
includeFiles: false,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
});
|
});
|
||||||
return (
|
return {
|
||||||
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
|
query: debouncedQuery,
|
||||||
);
|
paths:
|
||||||
|
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ??
|
||||||
|
[],
|
||||||
|
};
|
||||||
},
|
},
|
||||||
enabled: Boolean(client) && isConnected && open,
|
enabled: Boolean(client) && isConnected && open,
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(() => {
|
||||||
() =>
|
const currentSuggestions =
|
||||||
buildProjectPickerOptions({
|
directorySuggestionsQuery.data?.query === query ? directorySuggestionsQuery.data : null;
|
||||||
recommendedPaths,
|
return buildProjectPickerOptions({
|
||||||
serverPaths: directorySuggestionsQuery.data ?? [],
|
recommendedPaths,
|
||||||
query,
|
serverPaths: currentSuggestions?.paths ?? [],
|
||||||
}),
|
query,
|
||||||
[directorySuggestionsQuery.data, query, recommendedPaths],
|
});
|
||||||
);
|
}, [directorySuggestionsQuery.data, query, recommendedPaths]);
|
||||||
const hasQuery = query.trim().length > 0;
|
const hasQuery = query.trim().length > 0;
|
||||||
const isSearching =
|
const isSearching =
|
||||||
hasQuery &&
|
hasQuery &&
|
||||||
@@ -213,6 +219,8 @@ export function ProjectPickerModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setOpenErrorReason(getOpenProjectFailureReason(result));
|
setOpenErrorReason(getOpenProjectFailureReason(result));
|
||||||
|
} catch {
|
||||||
|
setOpenErrorReason("open_failed");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -232,6 +240,10 @@ export function ProjectPickerModal() {
|
|||||||
setOpenErrorReason(null);
|
setOpenErrorReason(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleBrowseError = useCallback(() => {
|
||||||
|
setOpenErrorReason("open_failed");
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
@@ -262,7 +274,7 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
function handler(event: KeyboardEvent) {
|
function handler(event: KeyboardEvent) {
|
||||||
const key = event.key;
|
const key = event.key;
|
||||||
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
|
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return;
|
||||||
|
|
||||||
if (key === "Escape") {
|
if (key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -270,12 +282,6 @@ export function ProjectPickerModal() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
submitActiveOption();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||||
if (options.length === 0) return;
|
if (options.length === 0) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -291,7 +297,7 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handler, true);
|
window.addEventListener("keydown", handler, true);
|
||||||
return () => window.removeEventListener("keydown", handler, true);
|
return () => window.removeEventListener("keydown", handler, true);
|
||||||
}, [close, open, options.length, submitActiveOption]);
|
}, [close, open, options.length]);
|
||||||
|
|
||||||
const panelStyle = useMemo(
|
const panelStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -330,6 +336,7 @@ export function ProjectPickerModal() {
|
|||||||
<View style={panelStyle}>
|
<View style={panelStyle}>
|
||||||
<View style={headerStyle}>
|
<View style={headerStyle}>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
testID="project-picker-input"
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={query}
|
value={query}
|
||||||
onChangeText={handleChangeQuery}
|
onChangeText={handleChangeQuery}
|
||||||
@@ -343,6 +350,12 @@ export function ProjectPickerModal() {
|
|||||||
returnKeyType="go"
|
returnKeyType="go"
|
||||||
onSubmitEditing={submitActiveOption}
|
onSubmitEditing={submitActiveOption}
|
||||||
/>
|
/>
|
||||||
|
<ProjectPickerBrowseButton
|
||||||
|
serverId={serverId}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onSelect={handleSelectPath}
|
||||||
|
onError={handleBrowseError}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ProjectPickerResults
|
<ProjectPickerResults
|
||||||
@@ -383,11 +396,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
...theme.shadow.lg,
|
...theme.shadow.lg,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
|
flex: 1,
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
outlineStyle: "none",
|
outlineStyle: "none",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe("buildProjectPickerOptions", () => {
|
|||||||
it("puts an absolute path row first", () => {
|
it("puts an absolute path row first", () => {
|
||||||
const options = buildProjectPickerOptions({
|
const options = buildProjectPickerOptions({
|
||||||
recommendedPaths: ["/repo/api"],
|
recommendedPaths: ["/repo/api"],
|
||||||
serverPaths: [],
|
serverPaths: ["/repo/api"],
|
||||||
query: "/repo",
|
query: "/repo",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ describe("buildProjectPickerOptions", () => {
|
|||||||
it("puts a home-relative path row first", () => {
|
it("puts a home-relative path row first", () => {
|
||||||
const options = buildProjectPickerOptions({
|
const options = buildProjectPickerOptions({
|
||||||
recommendedPaths: ["/Users/mo/src/api"],
|
recommendedPaths: ["/Users/mo/src/api"],
|
||||||
serverPaths: [],
|
serverPaths: ["/Users/mo/src/api"],
|
||||||
query: "~/src",
|
query: "~/src",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { createContext, memo, type ReactNode, useContext } from "react";
|
||||||
|
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
const RetainedPanelActiveContext = createContext(true);
|
||||||
|
|
||||||
|
export function useRetainedPanelActive(): boolean {
|
||||||
|
return useContext(RetainedPanelActiveContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RetainedPanelProps {
|
||||||
|
active: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RetainedPanelActivityProps {
|
||||||
|
active: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RetainedPanelActivity({ active, children }: RetainedPanelActivityProps) {
|
||||||
|
const parentActive = useRetainedPanelActive();
|
||||||
|
return (
|
||||||
|
<RetainedPanelActiveContext value={parentActive && active}>
|
||||||
|
{children}
|
||||||
|
</RetainedPanelActiveContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps expensive panel state mounted without letting an inactive panel render
|
||||||
|
* on screen. The stable, non-collapsible native root is intentional: retained
|
||||||
|
* panels must not detach or reparent their native descendants when visibility
|
||||||
|
* changes.
|
||||||
|
*/
|
||||||
|
export const RetainedPanel = memo(function RetainedPanel({
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
testID,
|
||||||
|
}: RetainedPanelProps) {
|
||||||
|
const visibleStyle = StyleSheet.compose<ViewStyle, ViewStyle, ViewStyle>(styles.root, style);
|
||||||
|
const panelStyle = active
|
||||||
|
? visibleStyle
|
||||||
|
: StyleSheet.compose<ViewStyle, ViewStyle, ViewStyle>(visibleStyle, styles.hidden);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RetainedPanelActivity active={active}>
|
||||||
|
<View
|
||||||
|
collapsable={false}
|
||||||
|
pointerEvents={active ? "auto" : "none"}
|
||||||
|
style={panelStyle}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</RetainedPanelActivity>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
hidden: {
|
||||||
|
display: "none",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -38,17 +38,13 @@ import { type GestureType } from "react-native-gesture-handler";
|
|||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
import {
|
||||||
Archive,
|
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
CircleCheck,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Settings,
|
Settings,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Pencil,
|
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
@@ -66,9 +62,7 @@ import {
|
|||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import {
|
import {
|
||||||
shouldShowSidebarHostLabels,
|
shouldShowSidebarHostLabels,
|
||||||
useSidebarWorkspaceEntry,
|
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarStatusWorkspacePlacement,
|
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
type SidebarWorkspacePlacement,
|
type SidebarWorkspacePlacement,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
@@ -83,7 +77,6 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { SyncedLoader } from "@/components/synced-loader";
|
import { SyncedLoader } from "@/components/synced-loader";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
|
||||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||||
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
||||||
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||||
@@ -93,6 +86,8 @@ import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
|||||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
||||||
|
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||||
|
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||||
import {
|
import {
|
||||||
SidebarWorkspaceRowFrame,
|
SidebarWorkspaceRowFrame,
|
||||||
SidebarWorkspaceRowContent,
|
SidebarWorkspaceRowContent,
|
||||||
@@ -116,7 +111,7 @@ import {
|
|||||||
} from "@/utils/sidebar-project-row-model";
|
} from "@/utils/sidebar-project-row-model";
|
||||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||||
import {
|
import {
|
||||||
getCurrentProjectRemoveReadiness,
|
getCurrentProjectRemoveReadiness,
|
||||||
@@ -142,15 +137,11 @@ const ThemedExternalLink = withUnistyles(ExternalLink);
|
|||||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
|
||||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||||
const ThemedPlus = withUnistyles(Plus);
|
const ThemedPlus = withUnistyles(Plus);
|
||||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||||
const ThemedTrash2 = withUnistyles(Trash2);
|
const ThemedTrash2 = withUnistyles(Trash2);
|
||||||
const ThemedSettings = withUnistyles(Settings);
|
const ThemedSettings = withUnistyles(Settings);
|
||||||
const ThemedCopy = withUnistyles(Copy);
|
|
||||||
const ThemedArchive = withUnistyles(Archive);
|
|
||||||
const ThemedPencil = withUnistyles(Pencil);
|
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({
|
const foregroundColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -228,8 +219,9 @@ function selectionForSelectedWorkspace(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarWorkspaceListProps {
|
interface SidebarWorkspaceListProps {
|
||||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
statusGroups: StatusGroup[];
|
||||||
projects: SidebarProjectEntry[];
|
projects: SidebarProjectEntry[];
|
||||||
|
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
collapsedProjectKeys: ReadonlySet<string>;
|
collapsedProjectKeys: ReadonlySet<string>;
|
||||||
onToggleProjectCollapsed: (projectKey: string) => void;
|
onToggleProjectCollapsed: (projectKey: string) => void;
|
||||||
@@ -291,16 +283,6 @@ interface WorkspaceRowInnerProps {
|
|||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWorkspaceArchiveStatus(
|
|
||||||
isWorktree: boolean,
|
|
||||||
archiveStatus: "idle" | "pending" | "success",
|
|
||||||
isArchivingWorkspace: boolean,
|
|
||||||
): "idle" | "pending" | "success" {
|
|
||||||
if (isWorktree) return archiveStatus;
|
|
||||||
if (isArchivingWorkspace) return "pending";
|
|
||||||
return "idle";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PrBadge({ hint }: { hint: PrHint }) {
|
export function PrBadge({ hint }: { hint: PrHint }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
@@ -358,12 +340,6 @@ function projectKebabStyle({
|
|||||||
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
|
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspaceKebabStyle({
|
|
||||||
hovered = false,
|
|
||||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
|
||||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProjectWorkspaceRowStyle({
|
function getProjectWorkspaceRowStyle({
|
||||||
isDragging,
|
isDragging,
|
||||||
selected,
|
selected,
|
||||||
@@ -540,12 +516,6 @@ function ProjectRowTrailingActions({
|
|||||||
|
|
||||||
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
const markAsReadLeadingIcon = (
|
|
||||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
|
||||||
);
|
|
||||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
const openInNewWindowLeadingIcon = (
|
const openInNewWindowLeadingIcon = (
|
||||||
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
|
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
|
||||||
);
|
);
|
||||||
@@ -693,7 +663,7 @@ function WorkspaceRowRightGroup({
|
|||||||
</SidebarWorkspaceTrailingActionBase>
|
</SidebarWorkspaceTrailingActionBase>
|
||||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
||||||
{onArchive ? (
|
{onArchive ? (
|
||||||
<WorkspaceKebabMenu
|
<SidebarWorkspaceMenu
|
||||||
workspaceKey={workspace.workspaceKey}
|
workspaceKey={workspace.workspaceKey}
|
||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
onCopyBranchName={onCopyBranchName}
|
onCopyBranchName={onCopyBranchName}
|
||||||
@@ -713,97 +683,6 @@ function WorkspaceRowRightGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceKebabMenu({
|
|
||||||
workspaceKey,
|
|
||||||
onCopyPath,
|
|
||||||
onCopyBranchName,
|
|
||||||
onRename,
|
|
||||||
onMarkAsRead,
|
|
||||||
onArchive,
|
|
||||||
archiveLabel,
|
|
||||||
archiveStatus,
|
|
||||||
archivePendingLabel,
|
|
||||||
archiveShortcutKeys,
|
|
||||||
}: {
|
|
||||||
workspaceKey: string;
|
|
||||||
onCopyPath?: () => void;
|
|
||||||
onCopyBranchName?: () => void;
|
|
||||||
onRename?: () => void;
|
|
||||||
onMarkAsRead?: () => void;
|
|
||||||
onArchive: () => void;
|
|
||||||
archiveLabel?: string;
|
|
||||||
archiveStatus?: "idle" | "pending" | "success";
|
|
||||||
archivePendingLabel?: string;
|
|
||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const archiveTrailing = useMemo(
|
|
||||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
|
||||||
[archiveShortcutKeys],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
hitSlop={8}
|
|
||||||
style={workspaceKebabStyle}
|
|
||||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
|
||||||
accessibilityLabel={t("sidebar.workspace.actions.menu")}
|
|
||||||
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
|
||||||
>
|
|
||||||
{renderKebabTriggerIcon}
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" width={260}>
|
|
||||||
{onCopyPath ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
|
||||||
leading={copyLeadingIcon}
|
|
||||||
onSelect={onCopyPath}
|
|
||||||
>
|
|
||||||
{t("sidebar.workspace.actions.copyPath")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onCopyBranchName ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
|
||||||
leading={copyLeadingIcon}
|
|
||||||
onSelect={onCopyBranchName}
|
|
||||||
>
|
|
||||||
{t("sidebar.workspace.actions.copyBranchName")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onRename ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
|
||||||
leading={renameLeadingIcon}
|
|
||||||
onSelect={onRename}
|
|
||||||
>
|
|
||||||
{t("sidebar.workspace.actions.rename")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onMarkAsRead ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
|
||||||
leading={markAsReadLeadingIcon}
|
|
||||||
onSelect={onMarkAsRead}
|
|
||||||
>
|
|
||||||
Mark as read
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
|
||||||
leading={archiveLeadingIcon}
|
|
||||||
trailing={archiveTrailing}
|
|
||||||
status={archiveStatus}
|
|
||||||
pendingLabel={archivePendingLabel}
|
|
||||||
onSelect={onArchive}
|
|
||||||
>
|
|
||||||
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectIcon({
|
function ProjectIcon({
|
||||||
iconDataUri,
|
iconDataUri,
|
||||||
placeholderInitial,
|
placeholderInitial,
|
||||||
@@ -1564,20 +1443,7 @@ function WorkspaceRowWithMenu({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
|
||||||
});
|
|
||||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
|
||||||
workspaceDirectory
|
|
||||||
? state.getStatus({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
cwd: workspaceDirectory,
|
|
||||||
actionId: "archive-worktree",
|
|
||||||
})
|
|
||||||
: "idle",
|
|
||||||
);
|
|
||||||
const isWorktree = workspace.workspaceKind === "worktree";
|
|
||||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
|
||||||
const redirectAfterArchive = useCallback(() => {
|
const redirectAfterArchive = useCallback(() => {
|
||||||
redirectIfArchivingActiveWorkspace({
|
redirectIfArchivingActiveWorkspace({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
@@ -1589,7 +1455,6 @@ function WorkspaceRowWithMenu({
|
|||||||
const archiveController = useWorkspaceArchive({
|
const archiveController = useWorkspaceArchive({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
|
||||||
workspaceKind: workspace.workspaceKind,
|
workspaceKind: workspace.workspaceKind,
|
||||||
name: workspace.name,
|
name: workspace.name,
|
||||||
...toWorktreeArchiveRisk(workspace),
|
...toWorktreeArchiveRisk(workspace),
|
||||||
@@ -1656,7 +1521,7 @@ function WorkspaceRowWithMenu({
|
|||||||
[renameMutation],
|
[renameMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
@@ -1668,8 +1533,8 @@ function WorkspaceRowWithMenu({
|
|||||||
}, [clearAttention, toast]);
|
}, [clearAttention, toast]);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||||
actions: ["worktree.archive"],
|
actions: ["workspace.archive"],
|
||||||
enabled: selected && !isArchiving,
|
enabled: selected && !isArchiving,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
handle: () => {
|
handle: () => {
|
||||||
@@ -1694,11 +1559,7 @@ function WorkspaceRowWithMenu({
|
|||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
menuController={null}
|
menuController={null}
|
||||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||||
archiveStatus={getWorkspaceArchiveStatus(
|
archiveStatus={isArchiving ? "pending" : "idle"}
|
||||||
isWorktree,
|
|
||||||
worktreeArchiveStatus,
|
|
||||||
isHidingWorkspace,
|
|
||||||
)}
|
|
||||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||||
onArchive={handleArchive}
|
onArchive={handleArchive}
|
||||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||||
@@ -1723,6 +1584,7 @@ function WorkspaceRowWithMenu({
|
|||||||
|
|
||||||
interface WorkspaceRowItemProps {
|
interface WorkspaceRowItemProps {
|
||||||
workspace: SidebarWorkspacePlacement;
|
workspace: SidebarWorkspacePlacement;
|
||||||
|
workspaceEntry: SidebarWorkspaceEntry | null;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
shortcutNumber: number | null;
|
shortcutNumber: number | null;
|
||||||
showShortcutBadge: boolean;
|
showShortcutBadge: boolean;
|
||||||
@@ -1738,6 +1600,7 @@ interface WorkspaceRowItemProps {
|
|||||||
|
|
||||||
function WorkspaceRowItem({
|
function WorkspaceRowItem({
|
||||||
workspace,
|
workspace,
|
||||||
|
workspaceEntry,
|
||||||
subtitle,
|
subtitle,
|
||||||
shortcutNumber,
|
shortcutNumber,
|
||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
@@ -1760,7 +1623,7 @@ function WorkspaceRowItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRow
|
<WorkspaceRow
|
||||||
workspace={workspace}
|
workspaceEntry={workspaceEntry}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
@@ -1798,6 +1661,7 @@ function areWorkspaceRowItemPropsEqual(
|
|||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
previous.workspace === next.workspace &&
|
previous.workspace === next.workspace &&
|
||||||
|
previous.workspaceEntry === next.workspaceEntry &&
|
||||||
previous.subtitle === next.subtitle &&
|
previous.subtitle === next.subtitle &&
|
||||||
previous.shortcutNumber === next.shortcutNumber &&
|
previous.shortcutNumber === next.shortcutNumber &&
|
||||||
previous.showShortcutBadge === next.showShortcutBadge &&
|
previous.showShortcutBadge === next.showShortcutBadge &&
|
||||||
@@ -1814,7 +1678,7 @@ function areWorkspaceRowItemPropsEqual(
|
|||||||
const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual);
|
const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual);
|
||||||
|
|
||||||
function WorkspaceRow({
|
function WorkspaceRow({
|
||||||
workspace,
|
workspaceEntry,
|
||||||
subtitle,
|
subtitle,
|
||||||
shortcutNumber,
|
shortcutNumber,
|
||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
@@ -1826,7 +1690,7 @@ function WorkspaceRow({
|
|||||||
isCreating = false,
|
isCreating = false,
|
||||||
selected,
|
selected,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspacePlacement;
|
workspaceEntry: SidebarWorkspaceEntry | null;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
shortcutNumber: number | null;
|
shortcutNumber: number | null;
|
||||||
showShortcutBadge: boolean;
|
showShortcutBadge: boolean;
|
||||||
@@ -1838,15 +1702,13 @@ function WorkspaceRow({
|
|||||||
isCreating?: boolean;
|
isCreating?: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
}) {
|
}) {
|
||||||
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
if (!workspaceEntry) {
|
||||||
|
|
||||||
if (!hydratedWorkspace) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRowWithMenu
|
<WorkspaceRowWithMenu
|
||||||
workspace={hydratedWorkspace}
|
workspace={workspaceEntry}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
@@ -1863,6 +1725,7 @@ function WorkspaceRow({
|
|||||||
|
|
||||||
function ProjectBlock({
|
function ProjectBlock({
|
||||||
project,
|
project,
|
||||||
|
workspaceEntriesByKey,
|
||||||
collapsed,
|
collapsed,
|
||||||
displayName,
|
displayName,
|
||||||
iconDataUri,
|
iconDataUri,
|
||||||
@@ -1885,6 +1748,7 @@ function ProjectBlock({
|
|||||||
supportsMultiplicityByServerId,
|
supportsMultiplicityByServerId,
|
||||||
}: {
|
}: {
|
||||||
project: SidebarProjectEntry;
|
project: SidebarProjectEntry;
|
||||||
|
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
iconDataUri: string | null;
|
iconDataUri: string | null;
|
||||||
@@ -1934,6 +1798,7 @@ function ProjectBlock({
|
|||||||
return (
|
return (
|
||||||
<MemoWorkspaceRowItem
|
<MemoWorkspaceRowItem
|
||||||
workspace={item}
|
workspace={item}
|
||||||
|
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
|
||||||
subtitle={
|
subtitle={
|
||||||
showHostLabels ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) : null
|
showHostLabels ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) : null
|
||||||
}
|
}
|
||||||
@@ -1960,6 +1825,7 @@ function ProjectBlock({
|
|||||||
selectionEnabled,
|
selectionEnabled,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
|
workspaceEntriesByKey,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2114,6 +1980,7 @@ type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
|||||||
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
||||||
return (
|
return (
|
||||||
previous.project === next.project &&
|
previous.project === next.project &&
|
||||||
|
previous.workspaceEntriesByKey === next.workspaceEntriesByKey &&
|
||||||
previous.collapsed === next.collapsed &&
|
previous.collapsed === next.collapsed &&
|
||||||
previous.displayName === next.displayName &&
|
previous.displayName === next.displayName &&
|
||||||
previous.iconDataUri === next.iconDataUri &&
|
previous.iconDataUri === next.iconDataUri &&
|
||||||
@@ -2166,8 +2033,9 @@ function areProjectBlockSelectionsEqual(
|
|||||||
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
||||||
|
|
||||||
export function SidebarWorkspaceList({
|
export function SidebarWorkspaceList({
|
||||||
statusWorkspacePlacements,
|
statusGroups,
|
||||||
projects,
|
projects,
|
||||||
|
workspaceEntriesByKey,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
onToggleProjectCollapsed,
|
onToggleProjectCollapsed,
|
||||||
@@ -2196,7 +2064,7 @@ export function SidebarWorkspaceList({
|
|||||||
const content =
|
const content =
|
||||||
groupMode === "status" ? (
|
groupMode === "status" ? (
|
||||||
<SidebarStatusModeWrapper
|
<SidebarStatusModeWrapper
|
||||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
statusGroups={statusGroups}
|
||||||
projectNamesByKey={projectNamesByKey}
|
projectNamesByKey={projectNamesByKey}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
@@ -2206,6 +2074,7 @@ export function SidebarWorkspaceList({
|
|||||||
) : (
|
) : (
|
||||||
<ProjectModeList
|
<ProjectModeList
|
||||||
projects={projects}
|
projects={projects}
|
||||||
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||||
collapsedProjectKeys={collapsedProjectKeys}
|
collapsedProjectKeys={collapsedProjectKeys}
|
||||||
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
@@ -2224,14 +2093,14 @@ export function SidebarWorkspaceList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SidebarStatusModeWrapper({
|
function SidebarStatusModeWrapper({
|
||||||
statusWorkspacePlacements,
|
statusGroups,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
hostLabelByServerId,
|
hostLabelByServerId,
|
||||||
showHostLabels,
|
showHostLabels,
|
||||||
}: {
|
}: {
|
||||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
statusGroups: StatusGroup[];
|
||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
@@ -2242,7 +2111,7 @@ function SidebarStatusModeWrapper({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarStatusWorkspaceList
|
<SidebarStatusWorkspaceList
|
||||||
workspaces={statusWorkspacePlacements}
|
groups={statusGroups}
|
||||||
projectNamesByKey={projectNamesByKey}
|
projectNamesByKey={projectNamesByKey}
|
||||||
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
||||||
showShortcutBadges={showShortcutBadges}
|
showShortcutBadges={showShortcutBadges}
|
||||||
@@ -2255,6 +2124,7 @@ function SidebarStatusModeWrapper({
|
|||||||
|
|
||||||
function ProjectModeList({
|
function ProjectModeList({
|
||||||
projects,
|
projects,
|
||||||
|
workspaceEntriesByKey,
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
onToggleProjectCollapsed,
|
onToggleProjectCollapsed,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
@@ -2268,7 +2138,7 @@ function ProjectModeList({
|
|||||||
supportsMultiplicityByServerId,
|
supportsMultiplicityByServerId,
|
||||||
}: Omit<
|
}: Omit<
|
||||||
SidebarWorkspaceListProps,
|
SidebarWorkspaceListProps,
|
||||||
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
"statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||||
> & {
|
> & {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||||
@@ -2443,6 +2313,7 @@ function ProjectModeList({
|
|||||||
return (
|
return (
|
||||||
<MemoProjectBlock
|
<MemoProjectBlock
|
||||||
project={item}
|
project={item}
|
||||||
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||||
collapsed={collapsedProjectKeys.has(item.projectKey)}
|
collapsed={collapsedProjectKeys.has(item.projectKey)}
|
||||||
displayName={item.projectName}
|
displayName={item.projectName}
|
||||||
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
||||||
@@ -2481,6 +2352,7 @@ function ProjectModeList({
|
|||||||
selectionEnabled,
|
selectionEnabled,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
|
workspaceEntriesByKey,
|
||||||
creatingWorkspaceIds,
|
creatingWorkspaceIds,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -2872,14 +2744,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
kebabButton: {
|
|
||||||
padding: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
marginLeft: 2,
|
|
||||||
},
|
|
||||||
kebabButtonHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
statusDotNeedsInput: {
|
statusDotNeedsInput: {
|
||||||
backgroundColor: theme.colors.palette.amber[500],
|
backgroundColor: theme.colors.palette.amber[500],
|
||||||
borderColor: theme.colors.surface0,
|
borderColor: theme.colors.surface0,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
useSidebarWorkspacesList,
|
||||||
|
type SidebarWorkspaceEntry,
|
||||||
|
type SidebarWorkspacesListResult,
|
||||||
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { useSidebarWorkspaceEntries } from "@/hooks/use-sidebar-workspace-entries";
|
||||||
|
import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||||
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
|
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||||
|
import {
|
||||||
|
buildSidebarShortcutModel,
|
||||||
|
buildStatusSidebarShortcutModel,
|
||||||
|
type SidebarShortcutModel,
|
||||||
|
} from "@/utils/sidebar-shortcuts";
|
||||||
|
|
||||||
|
interface SidebarModel extends SidebarWorkspacesListResult {
|
||||||
|
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||||
|
groupMode: SidebarGroupMode;
|
||||||
|
statusGroups: StatusGroup[];
|
||||||
|
collapsedProjectKeys: ReadonlySet<string>;
|
||||||
|
toggleProjectCollapsed: (projectKey: string) => void;
|
||||||
|
shortcutModel: SidebarShortcutModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarModelContext = createContext<SidebarModel | null>(null);
|
||||||
|
|
||||||
|
export function SidebarModelProvider({
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const list = useSidebarWorkspacesList();
|
||||||
|
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||||
|
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||||
|
(state) => state.collapsedProjectKeys,
|
||||||
|
);
|
||||||
|
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||||
|
(state) => state.collapsedStatusGroupKeys,
|
||||||
|
);
|
||||||
|
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
|
||||||
|
(state) => state.toggleProjectCollapsed,
|
||||||
|
);
|
||||||
|
const isStatusMode = groupMode === "status";
|
||||||
|
const workspaceEntriesByKey = useSidebarWorkspaceEntries(
|
||||||
|
list.workspacePlacements,
|
||||||
|
active !== false || isStatusMode,
|
||||||
|
);
|
||||||
|
const statusGroups = useMemo(
|
||||||
|
() =>
|
||||||
|
isStatusMode
|
||||||
|
? buildStatusGroups(Array.from(workspaceEntriesByKey.values()), list.projectNamesByKey)
|
||||||
|
: [],
|
||||||
|
[isStatusMode, list.projectNamesByKey, workspaceEntriesByKey],
|
||||||
|
);
|
||||||
|
const shortcutModel = useMemo(() => {
|
||||||
|
if (isStatusMode) {
|
||||||
|
return buildStatusSidebarShortcutModel({
|
||||||
|
groups: statusGroups,
|
||||||
|
collapsedStatusGroupKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return buildSidebarShortcutModel({ projects: list.projects, collapsedProjectKeys });
|
||||||
|
}, [collapsedProjectKeys, collapsedStatusGroupKeys, isStatusMode, list.projects, statusGroups]);
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
...list,
|
||||||
|
workspaceEntriesByKey,
|
||||||
|
groupMode,
|
||||||
|
statusGroups,
|
||||||
|
collapsedProjectKeys,
|
||||||
|
toggleProjectCollapsed,
|
||||||
|
shortcutModel,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
collapsedProjectKeys,
|
||||||
|
groupMode,
|
||||||
|
list,
|
||||||
|
shortcutModel,
|
||||||
|
statusGroups,
|
||||||
|
toggleProjectCollapsed,
|
||||||
|
workspaceEntriesByKey,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SidebarModelContext.Provider value={value}>{children}</SidebarModelContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarModel(): SidebarModel {
|
||||||
|
const model = useContext(SidebarModelContext);
|
||||||
|
if (!model) throw new Error("SidebarModelProvider is required");
|
||||||
|
return model;
|
||||||
|
}
|
||||||
@@ -4,16 +4,8 @@ import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } fr
|
|||||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import {
|
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
useSidebarWorkspaceEntry,
|
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||||
type SidebarStatusWorkspacePlacement,
|
|
||||||
type SidebarWorkspaceEntry,
|
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
|
||||||
import {
|
|
||||||
buildStatusGroups,
|
|
||||||
buildStatusShortcutIndex,
|
|
||||||
type StatusGroup,
|
|
||||||
} from "@/hooks/sidebar-status-view-model";
|
|
||||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
@@ -25,29 +17,17 @@ import {
|
|||||||
CircleCheck,
|
CircleCheck,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
CircleX,
|
CircleX,
|
||||||
MoreVertical,
|
|
||||||
Copy,
|
|
||||||
Archive,
|
|
||||||
Pencil,
|
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
|
||||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
@@ -60,9 +40,9 @@ import {
|
|||||||
SidebarWorkspaceTrailingActionSlot,
|
SidebarWorkspaceTrailingActionSlot,
|
||||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
|
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||||
|
|
||||||
// Themed icon wrappers
|
// Themed icon wrappers
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
});
|
||||||
@@ -77,20 +57,8 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
|
|||||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||||
const ThemedCircleX = withUnistyles(CircleX);
|
const ThemedCircleX = withUnistyles(CircleX);
|
||||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
|
||||||
const ThemedCopy = withUnistyles(Copy);
|
|
||||||
const ThemedArchive = withUnistyles(Archive);
|
|
||||||
const ThemedPencil = withUnistyles(Pencil);
|
|
||||||
|
|
||||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
const markAsReadLeadingIcon = (
|
|
||||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
|
||||||
);
|
|
||||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
|
|
||||||
interface StatusWorkspaceListProps {
|
interface StatusWorkspaceListProps {
|
||||||
workspaces: SidebarStatusWorkspacePlacement[];
|
groups: StatusGroup[];
|
||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
showShortcutBadges: boolean;
|
showShortcutBadges: boolean;
|
||||||
@@ -100,31 +68,19 @@ interface StatusWorkspaceListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarStatusWorkspaceList({
|
export function SidebarStatusWorkspaceList({
|
||||||
workspaces,
|
groups,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
hostLabelByServerId,
|
hostLabelByServerId,
|
||||||
showHostLabels,
|
showHostLabels,
|
||||||
}: StatusWorkspaceListProps) {
|
}: StatusWorkspaceListProps) {
|
||||||
const groups = useMemo(
|
|
||||||
() => buildStatusGroups(workspaces, projectNamesByKey),
|
|
||||||
[workspaces, projectNamesByKey],
|
|
||||||
);
|
|
||||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.collapsedStatusGroupKeys,
|
(state) => state.collapsedStatusGroupKeys,
|
||||||
);
|
);
|
||||||
|
|
||||||
const statusShortcutIndex = useMemo(
|
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
||||||
() =>
|
|
||||||
showShortcutBadges
|
|
||||||
? buildStatusShortcutIndex(
|
|
||||||
groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)),
|
|
||||||
)
|
|
||||||
: new Map<string, number>(),
|
|
||||||
[collapsedStatusGroupKeys, groups, showShortcutBadges],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -325,13 +281,12 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
|||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarStatusWorkspacePlacement;
|
workspace: SidebarWorkspaceEntry;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
shortcutNumber: number | null;
|
shortcutNumber: number | null;
|
||||||
showShortcutBadge: boolean;
|
showShortcutBadge: boolean;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
|
||||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||||
const selected =
|
const selected =
|
||||||
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
||||||
@@ -343,11 +298,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
|||||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
||||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||||
|
|
||||||
if (!workspaceEntry) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusWorkspaceRowWithMenu
|
<StatusWorkspaceRowWithMenu
|
||||||
workspace={workspaceEntry}
|
workspace={workspace}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
@@ -376,20 +329,7 @@ function StatusWorkspaceRowWithMenu({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
|
||||||
});
|
|
||||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
|
||||||
workspaceDirectory
|
|
||||||
? state.getStatus({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
cwd: workspaceDirectory,
|
|
||||||
actionId: "archive-worktree",
|
|
||||||
})
|
|
||||||
: "idle",
|
|
||||||
);
|
|
||||||
const isWorktree = workspace.workspaceKind === "worktree";
|
|
||||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
|
||||||
|
|
||||||
const redirectAfterArchive = useCallback(() => {
|
const redirectAfterArchive = useCallback(() => {
|
||||||
redirectIfArchivingActiveWorkspace({
|
redirectIfArchivingActiveWorkspace({
|
||||||
@@ -404,7 +344,6 @@ function StatusWorkspaceRowWithMenu({
|
|||||||
const archiveController = useWorkspaceArchive({
|
const archiveController = useWorkspaceArchive({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
|
||||||
workspaceKind: workspace.workspaceKind,
|
workspaceKind: workspace.workspaceKind,
|
||||||
name: workspace.name,
|
name: workspace.name,
|
||||||
...toWorktreeArchiveRisk(workspace),
|
...toWorktreeArchiveRisk(workspace),
|
||||||
@@ -454,7 +393,7 @@ function StatusWorkspaceRowWithMenu({
|
|||||||
[renameMutation],
|
[renameMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
@@ -466,8 +405,8 @@ function StatusWorkspaceRowWithMenu({
|
|||||||
}, [clearAttention, toast]);
|
}, [clearAttention, toast]);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||||
actions: ["worktree.archive"],
|
actions: ["workspace.archive"],
|
||||||
enabled: selected && !isArchiving,
|
enabled: selected && !isArchiving,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
handle: () => {
|
handle: () => {
|
||||||
@@ -476,13 +415,6 @@ function StatusWorkspaceRowWithMenu({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
|
|
||||||
if (isWorktree) {
|
|
||||||
computedArchiveStatus = worktreeArchiveStatus;
|
|
||||||
} else if (isHidingWorkspace) {
|
|
||||||
computedArchiveStatus = "pending";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusWorkspaceRowInner
|
<StatusWorkspaceRowInner
|
||||||
@@ -494,7 +426,7 @@ function StatusWorkspaceRowWithMenu({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
isArchiving={isArchiving}
|
isArchiving={isArchiving}
|
||||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||||
archiveStatus={computedArchiveStatus}
|
archiveStatus={isArchiving ? "pending" : "idle"}
|
||||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||||
onArchive={handleArchive}
|
onArchive={handleArchive}
|
||||||
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
|
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
|
||||||
@@ -657,7 +589,7 @@ function StatusWorkspaceActionSlot({
|
|||||||
</SidebarWorkspaceTrailingActionBase>
|
</SidebarWorkspaceTrailingActionBase>
|
||||||
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
|
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
|
||||||
{onArchive ? (
|
{onArchive ? (
|
||||||
<StatusKebabMenu
|
<SidebarWorkspaceMenu
|
||||||
workspaceKey={workspace.workspaceKey}
|
workspaceKey={workspace.workspaceKey}
|
||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
onCopyBranchName={onCopyBranchName}
|
onCopyBranchName={onCopyBranchName}
|
||||||
@@ -675,105 +607,6 @@ function StatusWorkspaceActionSlot({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusKebabMenu({
|
|
||||||
workspaceKey,
|
|
||||||
onCopyPath,
|
|
||||||
onCopyBranchName,
|
|
||||||
onRename,
|
|
||||||
onMarkAsRead,
|
|
||||||
onArchive,
|
|
||||||
archiveLabel,
|
|
||||||
archiveStatus,
|
|
||||||
archivePendingLabel,
|
|
||||||
archiveShortcutKeys,
|
|
||||||
}: {
|
|
||||||
workspaceKey: string;
|
|
||||||
onCopyPath?: () => void;
|
|
||||||
onCopyBranchName?: () => void;
|
|
||||||
onRename?: () => void;
|
|
||||||
onMarkAsRead?: () => void;
|
|
||||||
onArchive: () => void;
|
|
||||||
archiveLabel?: string;
|
|
||||||
archiveStatus?: "idle" | "pending" | "success";
|
|
||||||
archivePendingLabel?: string;
|
|
||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
||||||
}) {
|
|
||||||
const archiveTrailing = useMemo(
|
|
||||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
|
||||||
[archiveShortcutKeys],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
hitSlop={8}
|
|
||||||
style={kebabStyle}
|
|
||||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
|
||||||
accessibilityLabel="Workspace actions"
|
|
||||||
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
|
||||||
>
|
|
||||||
{({ hovered }: { hovered?: boolean }) => (
|
|
||||||
<ThemedMoreVertical
|
|
||||||
size={14}
|
|
||||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" width={260}>
|
|
||||||
{onCopyPath ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
|
||||||
leading={copyLeadingIcon}
|
|
||||||
onSelect={onCopyPath}
|
|
||||||
>
|
|
||||||
Copy path
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onCopyBranchName ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
|
||||||
leading={copyLeadingIcon}
|
|
||||||
onSelect={onCopyBranchName}
|
|
||||||
>
|
|
||||||
Copy branch name
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onRename ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
|
||||||
leading={renameLeadingIcon}
|
|
||||||
onSelect={onRename}
|
|
||||||
>
|
|
||||||
Rename workspace
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onMarkAsRead ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
|
||||||
leading={markAsReadLeadingIcon}
|
|
||||||
onSelect={onMarkAsRead}
|
|
||||||
>
|
|
||||||
Mark as read
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
|
||||||
leading={archiveLeadingIcon}
|
|
||||||
trailing={archiveTrailing}
|
|
||||||
status={archiveStatus}
|
|
||||||
pendingLabel={archivePendingLabel}
|
|
||||||
onSelect={onArchive}
|
|
||||||
>
|
|
||||||
{archiveLabel ?? "Archive"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
|
||||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusWorkspaceRowStyle({
|
function getStatusWorkspaceRowStyle({
|
||||||
selected,
|
selected,
|
||||||
isHovered,
|
isHovered,
|
||||||
@@ -876,12 +709,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
sidebarRowSelected: {
|
sidebarRowSelected: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
kebabButton: {
|
|
||||||
padding: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
marginLeft: 2,
|
|
||||||
},
|
|
||||||
kebabButtonHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { type PressableStateCallbackType } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
|
||||||
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
|
||||||
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||||
|
const ThemedCopy = withUnistyles(Copy);
|
||||||
|
const ThemedArchive = withUnistyles(Archive);
|
||||||
|
const ThemedPencil = withUnistyles(Pencil);
|
||||||
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||||
|
|
||||||
|
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const markAsReadLeadingIcon = (
|
||||||
|
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||||
|
);
|
||||||
|
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
|
||||||
|
function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||||
|
return (
|
||||||
|
<ThemedMoreVertical
|
||||||
|
size={14}
|
||||||
|
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarWorkspaceMenuProps {
|
||||||
|
workspaceKey: string;
|
||||||
|
onCopyPath?: () => void;
|
||||||
|
onCopyBranchName?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
|
onMarkAsRead?: () => void;
|
||||||
|
onArchive: () => void;
|
||||||
|
archiveLabel?: string;
|
||||||
|
archiveStatus?: "idle" | "pending" | "success";
|
||||||
|
archivePendingLabel?: string;
|
||||||
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarWorkspaceMenu({
|
||||||
|
workspaceKey,
|
||||||
|
onCopyPath,
|
||||||
|
onCopyBranchName,
|
||||||
|
onRename,
|
||||||
|
onMarkAsRead,
|
||||||
|
onArchive,
|
||||||
|
archiveLabel,
|
||||||
|
archiveStatus,
|
||||||
|
archivePendingLabel,
|
||||||
|
archiveShortcutKeys,
|
||||||
|
}: SidebarWorkspaceMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const archiveTrailing = useMemo(
|
||||||
|
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||||
|
[archiveShortcutKeys],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
hitSlop={8}
|
||||||
|
style={triggerStyle}
|
||||||
|
accessibilityRole={isWeb ? undefined : "button"}
|
||||||
|
accessibilityLabel={t("sidebar.workspace.actions.menu")}
|
||||||
|
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
||||||
|
>
|
||||||
|
{renderTriggerIcon}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={260}>
|
||||||
|
{onCopyPath ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||||
|
leading={copyLeadingIcon}
|
||||||
|
onSelect={onCopyPath}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.copyPath")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onCopyBranchName ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||||
|
leading={copyLeadingIcon}
|
||||||
|
onSelect={onCopyBranchName}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.copyBranchName")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onRename ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||||
|
leading={renameLeadingIcon}
|
||||||
|
onSelect={onRename}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.rename")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onMarkAsRead ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||||
|
leading={markAsReadLeadingIcon}
|
||||||
|
onSelect={onMarkAsRead}
|
||||||
|
>
|
||||||
|
Mark as read
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||||
|
leading={archiveLeadingIcon}
|
||||||
|
trailing={archiveTrailing}
|
||||||
|
status={archiveStatus}
|
||||||
|
pendingLabel={archivePendingLabel}
|
||||||
|
onSelect={onArchive}
|
||||||
|
>
|
||||||
|
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
|
return [styles.trigger, hovered && styles.triggerHovered];
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
trigger: {
|
||||||
|
padding: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
triggerHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -142,7 +142,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
|||||||
</Text>
|
</Text>
|
||||||
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
|
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.workspaceRowRight}>{children}</View>
|
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
||||||
</View>
|
</View>
|
||||||
{subtitle ? (
|
{subtitle ? (
|
||||||
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
|
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
|
||||||
@@ -502,7 +502,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
workspaceRowRight: sidebarWorkspaceRowStyles.rowRight,
|
|
||||||
shortcutBadgeOverlay: {
|
shortcutBadgeOverlay: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 1,
|
top: 1,
|
||||||
|
|||||||
@@ -1,35 +1,26 @@
|
|||||||
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
import { View, Text, Pressable } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
|
||||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
|
||||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
import { isNative as platformIsNative } from "@/constants/platform";
|
||||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||||
|
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||||
import {
|
import {
|
||||||
SidebarWorkspaceRowFrame,
|
SidebarWorkspaceRowFrame,
|
||||||
SidebarWorkspaceRowContent,
|
SidebarWorkspaceRowContent,
|
||||||
@@ -38,31 +29,6 @@ import {
|
|||||||
SidebarWorkspaceTrailingActionSlot,
|
SidebarWorkspaceTrailingActionSlot,
|
||||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
|
||||||
|
|
||||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
|
||||||
const ThemedCopy = withUnistyles(Copy);
|
|
||||||
const ThemedArchive = withUnistyles(Archive);
|
|
||||||
const ThemedPencil = withUnistyles(Pencil);
|
|
||||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
|
||||||
|
|
||||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
const markAsReadLeadingIcon = (
|
|
||||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
|
||||||
);
|
|
||||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
|
|
||||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
|
||||||
return (
|
|
||||||
<ThemedMoreVertical
|
|
||||||
size={14}
|
|
||||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
|
|
||||||
interface SidebarWorkspaceRowProps {
|
interface SidebarWorkspaceRowProps {
|
||||||
@@ -99,20 +65,7 @@ export function SidebarWorkspaceRow({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
|
||||||
});
|
|
||||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
|
||||||
workspaceDirectory
|
|
||||||
? state.getStatus({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
cwd: workspaceDirectory,
|
|
||||||
actionId: "archive-worktree",
|
|
||||||
})
|
|
||||||
: "idle",
|
|
||||||
);
|
|
||||||
const isWorktree = workspace.workspaceKind === "worktree";
|
|
||||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
|
||||||
|
|
||||||
const redirectAfterArchive = useCallback(() => {
|
const redirectAfterArchive = useCallback(() => {
|
||||||
redirectIfArchivingActiveWorkspace({
|
redirectIfArchivingActiveWorkspace({
|
||||||
@@ -127,7 +80,6 @@ export function SidebarWorkspaceRow({
|
|||||||
const archiveController = useWorkspaceArchive({
|
const archiveController = useWorkspaceArchive({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
|
||||||
workspaceKind: workspace.workspaceKind,
|
workspaceKind: workspace.workspaceKind,
|
||||||
name: workspace.name,
|
name: workspace.name,
|
||||||
...toWorktreeArchiveRisk(workspace),
|
...toWorktreeArchiveRisk(workspace),
|
||||||
@@ -194,7 +146,7 @@ export function SidebarWorkspaceRow({
|
|||||||
[renameMutation],
|
[renameMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
@@ -206,8 +158,8 @@ export function SidebarWorkspaceRow({
|
|||||||
}, [clearAttention, toast]);
|
}, [clearAttention, toast]);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||||
actions: ["worktree.archive"],
|
actions: ["workspace.archive"],
|
||||||
enabled: selected && !isArchiving,
|
enabled: selected && !isArchiving,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
handle: () => {
|
handle: () => {
|
||||||
@@ -216,13 +168,6 @@ export function SidebarWorkspaceRow({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let archiveStatus: "idle" | "pending" | "success" = "idle";
|
|
||||||
if (isWorktree) {
|
|
||||||
archiveStatus = worktreeArchiveStatus;
|
|
||||||
} else if (isHidingWorkspace) {
|
|
||||||
archiveStatus = "pending";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WorkspaceRowBody
|
<WorkspaceRowBody
|
||||||
@@ -238,7 +183,7 @@ export function SidebarWorkspaceRow({
|
|||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||||
archiveStatus={archiveStatus}
|
archiveStatus={isArchiving ? "pending" : "idle"}
|
||||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||||
onArchive={handleArchive}
|
onArchive={handleArchive}
|
||||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||||
@@ -458,7 +403,7 @@ function WorkspaceRowTrailingActions({
|
|||||||
</SidebarWorkspaceTrailingActionBase>
|
</SidebarWorkspaceTrailingActionBase>
|
||||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
||||||
{onArchive ? (
|
{onArchive ? (
|
||||||
<WorkspaceKebabMenu
|
<SidebarWorkspaceMenu
|
||||||
workspaceKey={workspace.workspaceKey}
|
workspaceKey={workspace.workspaceKey}
|
||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
onCopyBranchName={onCopyBranchName}
|
onCopyBranchName={onCopyBranchName}
|
||||||
@@ -478,103 +423,6 @@ function WorkspaceRowTrailingActions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceKebabMenu({
|
|
||||||
workspaceKey,
|
|
||||||
onCopyPath,
|
|
||||||
onCopyBranchName,
|
|
||||||
onRename,
|
|
||||||
onMarkAsRead,
|
|
||||||
onArchive,
|
|
||||||
archiveLabel,
|
|
||||||
archiveStatus,
|
|
||||||
archivePendingLabel,
|
|
||||||
archiveShortcutKeys,
|
|
||||||
}: {
|
|
||||||
workspaceKey: string;
|
|
||||||
onCopyPath?: () => void;
|
|
||||||
onCopyBranchName?: () => void;
|
|
||||||
onRename?: () => void;
|
|
||||||
onMarkAsRead?: () => void;
|
|
||||||
onArchive: () => void;
|
|
||||||
archiveLabel?: string;
|
|
||||||
archiveStatus?: "idle" | "pending" | "success";
|
|
||||||
archivePendingLabel?: string;
|
|
||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const archiveTrailing = useMemo(
|
|
||||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
|
||||||
[archiveShortcutKeys],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
hitSlop={8}
|
|
||||||
style={workspaceKebabStyle}
|
|
||||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
|
||||||
accessibilityLabel={t("sidebar.workspace.actions.menu")}
|
|
||||||
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
|
||||||
>
|
|
||||||
{renderKebabTriggerIcon}
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" width={260}>
|
|
||||||
{onCopyPath ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
|
||||||
leading={copyLeadingIcon}
|
|
||||||
onSelect={onCopyPath}
|
|
||||||
>
|
|
||||||
{t("sidebar.workspace.actions.copyPath")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onCopyBranchName ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
|
||||||
leading={copyLeadingIcon}
|
|
||||||
onSelect={onCopyBranchName}
|
|
||||||
>
|
|
||||||
{t("sidebar.workspace.actions.copyBranchName")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onRename ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
|
||||||
leading={renameLeadingIcon}
|
|
||||||
onSelect={onRename}
|
|
||||||
>
|
|
||||||
{t("sidebar.workspace.actions.rename")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onMarkAsRead ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
|
||||||
leading={markAsReadLeadingIcon}
|
|
||||||
onSelect={onMarkAsRead}
|
|
||||||
>
|
|
||||||
Mark as read
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
|
||||||
leading={archiveLeadingIcon}
|
|
||||||
trailing={archiveTrailing}
|
|
||||||
status={archiveStatus}
|
|
||||||
pendingLabel={archivePendingLabel}
|
|
||||||
onSelect={onArchive}
|
|
||||||
>
|
|
||||||
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function workspaceKebabStyle({
|
|
||||||
hovered = false,
|
|
||||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
|
||||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWorkspaceRowStyle({
|
function getWorkspaceRowStyle({
|
||||||
isDragging,
|
isDragging,
|
||||||
selected,
|
selected,
|
||||||
@@ -630,12 +478,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
kebabButton: {
|
|
||||||
padding: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
marginLeft: 2,
|
|
||||||
},
|
|
||||||
kebabButtonHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
createContext,
|
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -32,6 +31,7 @@ import { View, Text } from "react-native";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ResizeHandle } from "@/components/resize-handle";
|
import { ResizeHandle } from "@/components/resize-handle";
|
||||||
|
import { RetainedPanel } from "@/components/retained-panel";
|
||||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
@@ -74,10 +74,6 @@ import { RenderProfile } from "@/utils/render-profiler";
|
|||||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
// true = this tab slot is the active (visible) tab; false = mounted but hidden.
|
|
||||||
// Defaults to true so consumers outside a slot (e.g. web preview) are unaffected.
|
|
||||||
export const MountedTabActiveContext = createContext<boolean>(true);
|
|
||||||
|
|
||||||
interface SplitContainerProps {
|
interface SplitContainerProps {
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
workspaceKey: string;
|
workspaceKey: string;
|
||||||
@@ -214,26 +210,20 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
|||||||
[buildPaneContentModel, paneId, tabDescriptor],
|
[buildPaneContentModel, paneId, tabDescriptor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapperStyle = useMemo(() => {
|
|
||||||
const display: "flex" | "none" = isVisible ? "flex" : "none";
|
|
||||||
return { display, flex: 1 };
|
|
||||||
}, [isVisible]);
|
|
||||||
const handleFocusPane = useCallback(() => {
|
const handleFocusPane = useCallback(() => {
|
||||||
onFocusPane(paneId);
|
onFocusPane(paneId);
|
||||||
}, [onFocusPane, paneId]);
|
}, [onFocusPane, paneId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
||||||
<MountedTabActiveContext value={isVisible}>
|
<RetainedPanel active={isVisible}>
|
||||||
<View style={wrapperStyle}>
|
<WorkspacePaneContent
|
||||||
<WorkspacePaneContent
|
content={content}
|
||||||
content={content}
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
isWorkspaceFocused={isWorkspaceFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
isPaneFocused={isPaneFocused}
|
onFocusPane={handleFocusPane}
|
||||||
onFocusPane={handleFocusPane}
|
/>
|
||||||
/>
|
</RetainedPanel>
|
||||||
</View>
|
|
||||||
</MountedTabActiveContext>
|
|
||||||
</RenderProfile>
|
</RenderProfile>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { getSyncedLoaderDotOpacity, getSyncedLoaderStep } from "./synced-loader-state";
|
||||||
|
|
||||||
|
describe("synced loader state", () => {
|
||||||
|
test("advances through six wall-clock-aligned steps every 950 milliseconds", () => {
|
||||||
|
const sampleTimes = [0, 158, 159, 316, 317, 474, 475, 633, 634, 791, 792, 949, 950];
|
||||||
|
|
||||||
|
const steps = sampleTimes.map(getSyncedLoaderStep);
|
||||||
|
|
||||||
|
expect(steps).toEqual([0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves the six visible snake states", () => {
|
||||||
|
const states: number[][] = [];
|
||||||
|
for (let step = 0; step < 6; step += 1) {
|
||||||
|
const dotOpacities: number[] = [];
|
||||||
|
for (let dot = 0; dot < 6; dot += 1) {
|
||||||
|
dotOpacities.push(getSyncedLoaderDotOpacity(step, dot));
|
||||||
|
}
|
||||||
|
states.push(dotOpacities);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(states).toEqual([
|
||||||
|
[1, 0, 0.78, 0, 0.56, 0.34],
|
||||||
|
[0.78, 1, 0.56, 0, 0.34, 0],
|
||||||
|
[0.56, 0.78, 0.34, 1, 0, 0],
|
||||||
|
[0.34, 0.56, 0, 0.78, 0, 1],
|
||||||
|
[0, 0.34, 0, 0.56, 1, 0.78],
|
||||||
|
[0, 0, 1, 0.34, 0.78, 0.56],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const SYNCED_LOADER_DURATION_MS = 950;
|
||||||
|
export const SYNCED_LOADER_DOT_COUNT = 6;
|
||||||
|
|
||||||
|
const SYNCED_LOADER_OPACITY_STATES = [
|
||||||
|
[1, 0, 0.78, 0, 0.56, 0.34],
|
||||||
|
[0.78, 1, 0.56, 0, 0.34, 0],
|
||||||
|
[0.56, 0.78, 0.34, 1, 0, 0],
|
||||||
|
[0.34, 0.56, 0, 0.78, 0, 1],
|
||||||
|
[0, 0.34, 0, 0.56, 1, 0.78],
|
||||||
|
[0, 0, 1, 0.34, 0.78, 0.56],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function getSyncedLoaderStep(nowMs: number): number {
|
||||||
|
"worklet";
|
||||||
|
const elapsedMs = nowMs % SYNCED_LOADER_DURATION_MS;
|
||||||
|
return Math.floor((elapsedMs * SYNCED_LOADER_DOT_COUNT) / SYNCED_LOADER_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSyncedLoaderDotOpacity(step: number, dot: number): number {
|
||||||
|
"worklet";
|
||||||
|
return SYNCED_LOADER_OPACITY_STATES[step]?.[dot] ?? 0;
|
||||||
|
}
|
||||||
@@ -1,65 +1,101 @@
|
|||||||
|
import { useLayoutEffect, useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
|
||||||
makeMutable,
|
makeMutable,
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
withRepeat,
|
useReducedMotion,
|
||||||
withTiming,
|
useSharedValue,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useEffect, useMemo } from "react";
|
import { scheduleOnUI } from "react-native-worklets";
|
||||||
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
|
import {
|
||||||
|
SYNCED_LOADER_DOT_COUNT,
|
||||||
|
getSyncedLoaderDotOpacity,
|
||||||
|
getSyncedLoaderStep,
|
||||||
|
} from "@/components/synced-loader-state";
|
||||||
|
|
||||||
const SYNCED_LOADER_DURATION_MS = 950;
|
|
||||||
const SYNCED_LOADER_EPOCH_MS = 0;
|
|
||||||
const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const;
|
|
||||||
const DOT_COUNT = DOT_SEQUENCE.length;
|
|
||||||
const GRID_COLUMNS = 2;
|
const GRID_COLUMNS = 2;
|
||||||
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
|
const DOT_KEYS = Array.from({ length: SYNCED_LOADER_DOT_COUNT }, (_, i) => `dot-${i}`);
|
||||||
const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
|
const sharedStep = makeMutable(0);
|
||||||
const DOT_KEYS = Array.from({ length: DOT_COUNT }, (_, i) => `dot-${i}`);
|
const activeLoaderCount = makeMutable(0);
|
||||||
const sharedStepProgress = makeMutable(0);
|
const clockRunning = makeMutable(false);
|
||||||
let sharedLoopStarted = false;
|
let nextStepListenerId = 1;
|
||||||
|
|
||||||
function ensureSharedStepLoopStarted(): void {
|
function advanceSharedStep(): void {
|
||||||
if (sharedLoopStarted) {
|
"worklet";
|
||||||
|
if (activeLoaderCount.value === 0) {
|
||||||
|
clockRunning.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sharedLoopStarted = true;
|
const nextStep = getSyncedLoaderStep(Date.now());
|
||||||
const elapsedMs = (Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
|
if (sharedStep.value !== nextStep) {
|
||||||
sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT;
|
sharedStep.value = nextStep;
|
||||||
sharedStepProgress.value = withTiming(
|
}
|
||||||
DOT_COUNT,
|
requestAnimationFrame(advanceSharedStep);
|
||||||
{
|
}
|
||||||
duration: Math.max(1, Math.round(SYNCED_LOADER_DURATION_MS - elapsedMs)),
|
|
||||||
easing: Easing.linear,
|
function registerStepListener(
|
||||||
},
|
step: SharedValue<number>,
|
||||||
(finished) => {
|
registered: SharedValue<boolean>,
|
||||||
if (!finished) {
|
listenerId: number,
|
||||||
sharedLoopStarted = false;
|
): void {
|
||||||
return;
|
"worklet";
|
||||||
}
|
if (registered.value) {
|
||||||
sharedStepProgress.value = 0;
|
return;
|
||||||
sharedStepProgress.value = withRepeat(
|
}
|
||||||
withTiming(DOT_COUNT, {
|
|
||||||
duration: SYNCED_LOADER_DURATION_MS,
|
registered.value = true;
|
||||||
easing: Easing.linear,
|
step.value = getSyncedLoaderStep(Date.now());
|
||||||
}),
|
sharedStep.addListener(listenerId, (nextStep) => {
|
||||||
-1,
|
step.value = nextStep;
|
||||||
false,
|
});
|
||||||
);
|
activeLoaderCount.value += 1;
|
||||||
},
|
|
||||||
);
|
if (!clockRunning.value) {
|
||||||
|
clockRunning.value = true;
|
||||||
|
sharedStep.value = step.value;
|
||||||
|
requestAnimationFrame(advanceSharedStep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterStepListener(registered: SharedValue<boolean>, listenerId: number): void {
|
||||||
|
"worklet";
|
||||||
|
if (!registered.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
registered.value = false;
|
||||||
|
sharedStep.removeListener(listenerId);
|
||||||
|
activeLoaderCount.value -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSyncedLoaderStep(active: boolean, reduceMotion: boolean): SharedValue<number> {
|
||||||
|
// The local value lets retained loaders detach from the app-wide clock without
|
||||||
|
// unmounting their animated views or leaving hidden style worklets subscribed.
|
||||||
|
const step = useSharedValue(reduceMotion ? 0 : getSyncedLoaderStep(Date.now()));
|
||||||
|
const registered = useSharedValue(false);
|
||||||
|
const [listenerId] = useState(() => nextStepListenerId++);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!active || reduceMotion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleOnUI(registerStepListener, step, registered, listenerId);
|
||||||
|
return () => {
|
||||||
|
scheduleOnUI(unregisterStepListener, registered, listenerId);
|
||||||
|
};
|
||||||
|
}, [active, listenerId, reduceMotion, registered, step]);
|
||||||
|
|
||||||
|
return step;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) {
|
export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) {
|
||||||
useEffect(() => {
|
const active = useRetainedPanelActive();
|
||||||
ensureSharedStepLoopStarted();
|
const reduceMotion = useReducedMotion();
|
||||||
}, []);
|
const step = useSyncedLoaderStep(active, reduceMotion);
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: 1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const gap = Math.max(1, Math.round(size * 0.12));
|
const gap = Math.max(1, Math.round(size * 0.12));
|
||||||
const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3));
|
const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3));
|
||||||
@@ -67,10 +103,9 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
|
|||||||
const gridHeight = dotSize * 3 + gap * 2;
|
const gridHeight = dotSize * 3 + gap * 2;
|
||||||
|
|
||||||
const gridStyle = useMemo(
|
const gridStyle = useMemo(
|
||||||
() => [animatedStyle, { width: gridWidth, height: gridHeight }],
|
() => ({ width: gridWidth, height: gridHeight }),
|
||||||
[animatedStyle, gridWidth, gridHeight],
|
[gridHeight, gridWidth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
@@ -84,25 +119,24 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
<Animated.View style={gridStyle}>
|
<View style={gridStyle}>
|
||||||
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
|
{Array.from({ length: SYNCED_LOADER_DOT_COUNT }).map((_, dotIndex) => {
|
||||||
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
|
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
|
||||||
const columnIndex = dotIndex % GRID_COLUMNS;
|
const columnIndex = dotIndex % GRID_COLUMNS;
|
||||||
const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SpinnerDot
|
<SpinnerDot
|
||||||
key={DOT_KEYS[dotIndex]}
|
key={DOT_KEYS[dotIndex]}
|
||||||
color={color}
|
color={color}
|
||||||
dotSize={dotSize}
|
dotSize={dotSize}
|
||||||
sequenceIndex={sequenceIndex}
|
dotIndex={dotIndex}
|
||||||
progress={sharedStepProgress}
|
step={step}
|
||||||
left={columnIndex * (dotSize + gap)}
|
left={columnIndex * (dotSize + gap)}
|
||||||
top={rowIndex * (dotSize + gap)}
|
top={rowIndex * (dotSize + gap)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Animated.View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,35 +144,21 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
|
|||||||
function SpinnerDot({
|
function SpinnerDot({
|
||||||
color,
|
color,
|
||||||
dotSize,
|
dotSize,
|
||||||
sequenceIndex,
|
dotIndex,
|
||||||
progress,
|
step,
|
||||||
left,
|
left,
|
||||||
top,
|
top,
|
||||||
}: {
|
}: {
|
||||||
color: string;
|
color: string;
|
||||||
dotSize: number;
|
dotSize: number;
|
||||||
sequenceIndex: number;
|
dotIndex: number;
|
||||||
progress: SharedValue<number>;
|
step: SharedValue<number>;
|
||||||
left: number;
|
left: number;
|
||||||
top: number;
|
top: number;
|
||||||
}) {
|
}) {
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
const headIndex = Math.floor(progress.value) % DOT_COUNT;
|
opacity: getSyncedLoaderDotOpacity(step.value, dotIndex),
|
||||||
let opacity = 0;
|
}));
|
||||||
|
|
||||||
for (let segmentIndex = 0; segmentIndex < SNAKE_SEGMENT_OFFSETS.length; segmentIndex += 1) {
|
|
||||||
const activeSequenceIndex =
|
|
||||||
(headIndex + SNAKE_SEGMENT_OFFSETS[segmentIndex] + DOT_COUNT) % DOT_COUNT;
|
|
||||||
if (sequenceIndex === activeSequenceIndex) {
|
|
||||||
opacity = SNAKE_OPACITIES[segmentIndex] ?? 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
opacity,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const dotStyle = useMemo(
|
const dotStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function TerminalPane({
|
|||||||
return trimmed.length > 0 ? trimmed : undefined;
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
}, [settings.monoFontFamily]);
|
}, [settings.monoFontFamily]);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobilePanel.target);
|
||||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||||
const swipeGesturesEnabled = isMobile && mobileView === "agent";
|
const swipeGesturesEnabled = isMobile && mobileView === "agent";
|
||||||
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
|
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
|||||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
|
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
|
||||||
|
import { SidebarModelProvider } from "./sidebar/sidebar-model";
|
||||||
|
|
||||||
vi.hoisted(() => {
|
vi.hoisted(() => {
|
||||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||||
@@ -122,7 +123,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
|
|
||||||
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
|
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
root?.render(
|
||||||
|
<SidebarModelProvider>
|
||||||
|
<WorkspaceShortcutTargetsSubscriber enabled={true} />
|
||||||
|
</SidebarModelProvider>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||||
@@ -186,7 +191,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
root?.render(
|
||||||
|
<SidebarModelProvider>
|
||||||
|
<WorkspaceShortcutTargetsSubscriber enabled={true} />
|
||||||
|
</SidebarModelProvider>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||||
@@ -220,7 +229,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
root?.render(
|
||||||
|
<SidebarModelProvider>
|
||||||
|
<WorkspaceShortcutTargetsSubscriber enabled={true} />
|
||||||
|
</SidebarModelProvider>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||||
@@ -238,11 +251,19 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
|
|
||||||
it("clears targets when disabled", async () => {
|
it("clears targets when disabled", async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
root?.render(
|
||||||
|
<SidebarModelProvider>
|
||||||
|
<WorkspaceShortcutTargetsSubscriber enabled={true} />
|
||||||
|
</SidebarModelProvider>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} />);
|
root?.render(
|
||||||
|
<SidebarModelProvider>
|
||||||
|
<WorkspaceShortcutTargetsSubscriber enabled={false} />
|
||||||
|
</SidebarModelProvider>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
|
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
|
||||||
|
|||||||
@@ -1,56 +1,13 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect } from "react";
|
||||||
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||||
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
|
||||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
|
||||||
import {
|
|
||||||
buildSidebarShortcutModel,
|
|
||||||
buildStatusSidebarShortcutModel,
|
|
||||||
} from "@/utils/sidebar-shortcuts";
|
|
||||||
|
|
||||||
export function WorkspaceShortcutTargetsSubscriber({ enabled }: { enabled: boolean }) {
|
export function WorkspaceShortcutTargetsSubscriber({ enabled }: { enabled: boolean }) {
|
||||||
const { workspacePlacements, projects, projectNamesByKey } = useSidebarWorkspacesList({
|
const { shortcutModel } = useSidebarModel();
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
|
||||||
const isStatusMode = enabled && groupMode === "status";
|
|
||||||
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
|
||||||
placements: workspacePlacements,
|
|
||||||
enabled: isStatusMode,
|
|
||||||
});
|
|
||||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
|
||||||
(state) => state.collapsedProjectKeys,
|
|
||||||
);
|
|
||||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
|
||||||
(state) => state.collapsedStatusGroupKeys,
|
|
||||||
);
|
|
||||||
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
|
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
|
||||||
(state) => state.setSidebarShortcutWorkspaceTargets,
|
(state) => state.setSidebarShortcutWorkspaceTargets,
|
||||||
);
|
);
|
||||||
|
|
||||||
const shortcutModel = useMemo(() => {
|
|
||||||
if (groupMode === "status") {
|
|
||||||
return buildStatusSidebarShortcutModel({
|
|
||||||
workspaces: statusWorkspacePlacements,
|
|
||||||
projectNamesByKey,
|
|
||||||
collapsedStatusGroupKeys,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildSidebarShortcutModel({
|
|
||||||
projects,
|
|
||||||
collapsedProjectKeys,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
collapsedProjectKeys,
|
|
||||||
collapsedStatusGroupKeys,
|
|
||||||
groupMode,
|
|
||||||
projectNamesByKey,
|
|
||||||
projects,
|
|
||||||
statusWorkspacePlacements,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setSidebarShortcutWorkspaceTargets([]);
|
setSidebarShortcutWorkspaceTargets([]);
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { useWindowDimensions } from "react-native";
|
|
||||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
|
||||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
|
||||||
import {
|
|
||||||
getRightSidebarAnimationTargets,
|
|
||||||
shouldSyncSidebarAnimation,
|
|
||||||
} from "@/utils/sidebar-animation-state";
|
|
||||||
|
|
||||||
const ANIMATION_DURATION = 220;
|
|
||||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
|
||||||
// Keeps the overlay displayed long enough for the close animation to finish
|
|
||||||
// before React hides it.
|
|
||||||
const OVERLAY_CLOSE_GRACE_MS = 300;
|
|
||||||
interface ExplorerSidebarAnimationContextValue {
|
|
||||||
translateX: SharedValue<number>;
|
|
||||||
backdropOpacity: SharedValue<number>;
|
|
||||||
windowWidth: number;
|
|
||||||
animateToOpen: () => void;
|
|
||||||
animateToClose: () => void;
|
|
||||||
overlayVisible: boolean;
|
|
||||||
setOverlayPeek: (peek: boolean) => void;
|
|
||||||
isGesturing: SharedValue<boolean>;
|
|
||||||
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
|
||||||
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
|
||||||
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationContextValue | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
|
||||||
const { startMobilePanelTransition, settleMobilePanel } = useSidebarAnimation();
|
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
|
||||||
const isOpen = usePanelStore((state) =>
|
|
||||||
selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
|
||||||
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
|
||||||
const translateX = useSharedValue(initialTargets.translateX);
|
|
||||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
|
||||||
const isGesturing = useSharedValue(false);
|
|
||||||
const gestureAnimatingRef = useRef(false);
|
|
||||||
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
|
||||||
|
|
||||||
// React owns whether the overlay is displayed at all; the worklet owns its
|
|
||||||
// motion. See the matching block in sidebar-animation-context.tsx for why
|
|
||||||
// (Fabric re-applies stale animated props over committed props,
|
|
||||||
// reanimated#9635).
|
|
||||||
const [overlayPeek, setOverlayPeek] = useState(false);
|
|
||||||
const overlayTarget = isOpen || overlayPeek;
|
|
||||||
const [overlayVisible, setOverlayVisible] = useState(overlayTarget);
|
|
||||||
useEffect(() => {
|
|
||||||
if (overlayTarget) {
|
|
||||||
setOverlayVisible(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => setOverlayVisible(false), OVERLAY_CLOSE_GRACE_MS);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [overlayTarget]);
|
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
|
||||||
const prevIsOpen = useRef(isOpen);
|
|
||||||
const prevMobileView = useRef(mobileView);
|
|
||||||
const prevWindowWidth = useRef(windowWidth);
|
|
||||||
|
|
||||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
|
||||||
useEffect(() => {
|
|
||||||
const didStateChange = shouldSyncSidebarAnimation({
|
|
||||||
previousIsOpen: prevIsOpen.current,
|
|
||||||
nextIsOpen: isOpen,
|
|
||||||
previousWindowWidth: prevWindowWidth.current,
|
|
||||||
nextWindowWidth: windowWidth,
|
|
||||||
});
|
|
||||||
const didMobileViewChange = prevMobileView.current !== mobileView;
|
|
||||||
const previousIsOpen = prevIsOpen.current;
|
|
||||||
const previousMobileView = prevMobileView.current;
|
|
||||||
const ownsMobileViewChange =
|
|
||||||
previousMobileView === "file-explorer" || mobileView === "file-explorer";
|
|
||||||
prevIsOpen.current = isOpen;
|
|
||||||
prevMobileView.current = mobileView;
|
|
||||||
prevWindowWidth.current = windowWidth;
|
|
||||||
|
|
||||||
if (!didStateChange && !didMobileViewChange) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gestureAnimatingRef.current) {
|
|
||||||
gestureAnimatingRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
|
||||||
if (isGesturing.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
|
||||||
|
|
||||||
if (previousIsOpen !== isOpen) {
|
|
||||||
if (isOpen) {
|
|
||||||
if (isCompactLayout) {
|
|
||||||
startMobilePanelTransition("file-explorer");
|
|
||||||
}
|
|
||||||
translateX.value = withTiming(
|
|
||||||
targets.translateX,
|
|
||||||
{
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (!finished) return;
|
|
||||||
if (isCompactLayout) {
|
|
||||||
settleMobilePanel("file-explorer");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCompactLayout && mobileView === "agent") {
|
|
||||||
startMobilePanelTransition("agent");
|
|
||||||
}
|
|
||||||
translateX.value = withTiming(
|
|
||||||
targets.translateX,
|
|
||||||
{
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (!finished) return;
|
|
||||||
if (isCompactLayout && mobileView === "agent") {
|
|
||||||
settleMobilePanel("agent");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
translateX.value = targets.translateX;
|
|
||||||
backdropOpacity.value = targets.backdropOpacity;
|
|
||||||
if (isCompactLayout && ownsMobileViewChange) {
|
|
||||||
settleMobilePanel(mobileView);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isOpen,
|
|
||||||
mobileView,
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
isGesturing,
|
|
||||||
isCompactLayout,
|
|
||||||
startMobilePanelTransition,
|
|
||||||
settleMobilePanel,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const animateToOpen = useCallback(() => {
|
|
||||||
"worklet";
|
|
||||||
startMobilePanelTransition("file-explorer");
|
|
||||||
translateX.value = withTiming(
|
|
||||||
0,
|
|
||||||
{
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (!finished) return;
|
|
||||||
settleMobilePanel("file-explorer");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
backdropOpacity.value = withTiming(1, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
|
|
||||||
|
|
||||||
const animateToClose = useCallback(() => {
|
|
||||||
"worklet";
|
|
||||||
startMobilePanelTransition("agent");
|
|
||||||
translateX.value = withTiming(
|
|
||||||
windowWidth,
|
|
||||||
{
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (!finished) return;
|
|
||||||
settleMobilePanel("agent");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
backdropOpacity.value = withTiming(0, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
|
|
||||||
|
|
||||||
const value = useMemo<ExplorerSidebarAnimationContextValue>(
|
|
||||||
() => ({
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
overlayVisible,
|
|
||||||
setOverlayPeek,
|
|
||||||
isGesturing,
|
|
||||||
gestureAnimatingRef,
|
|
||||||
openGestureRef,
|
|
||||||
closeGestureRef,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
overlayVisible,
|
|
||||||
isGesturing,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ExplorerSidebarAnimationContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</ExplorerSidebarAnimationContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useExplorerSidebarAnimation() {
|
|
||||||
const context = useContext(ExplorerSidebarAnimationContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"useExplorerSidebarAnimation must be used within ExplorerSidebarAnimationProvider",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useExplorerSidebarAnimationOptional() {
|
|
||||||
return useContext(ExplorerSidebarAnimationContext);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
|
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||||
|
|
||||||
interface PendingWorkspaceArchive {
|
interface PendingWorkspaceArchive {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
workspaceDirectory: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
|
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
|
||||||
@@ -15,7 +14,6 @@ function pendingArchiveKey(input: { serverId: string; workspaceId: string }): st
|
|||||||
export function markWorkspaceArchivePending(input: {
|
export function markWorkspaceArchivePending(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
workspaceDirectory?: string | null;
|
|
||||||
}): void {
|
}): void {
|
||||||
const serverId = input.serverId.trim();
|
const serverId = input.serverId.trim();
|
||||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||||
@@ -26,7 +24,6 @@ export function markWorkspaceArchivePending(input: {
|
|||||||
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
|
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
|
||||||
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
|
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
|
|
||||||
});
|
});
|
||||||
pendingWorkspaceArchivesByServer.set(serverId, archives);
|
pendingWorkspaceArchivesByServer.set(serverId, archives);
|
||||||
}
|
}
|
||||||
@@ -54,7 +51,6 @@ export function clearWorkspaceArchivePending(input: {
|
|||||||
export function isWorkspaceArchivePending(input: {
|
export function isWorkspaceArchivePending(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
workspaceDirectory?: string | null;
|
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const serverId = input.serverId.trim();
|
const serverId = input.serverId.trim();
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
@@ -67,21 +63,7 @@ export function isWorkspaceArchivePending(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||||
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
|
return Boolean(workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId })));
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
|
|
||||||
if (!workspaceDirectory) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const archive of archives.values()) {
|
|
||||||
if (archive.workspaceDirectory === workspaceDirectory) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldSuppressWorkspaceForLocalArchive(input: {
|
export function shouldSuppressWorkspaceForLocalArchive(input: {
|
||||||
@@ -91,6 +73,5 @@ export function shouldSuppressWorkspaceForLocalArchive(input: {
|
|||||||
return isWorkspaceArchivePending({
|
return isWorkspaceArchivePending({
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
workspaceId: input.workspace.id,
|
workspaceId: input.workspace.id,
|
||||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,388 +0,0 @@
|
|||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { Keyboard, useWindowDimensions } from "react-native";
|
|
||||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { isNative } from "@/constants/platform";
|
|
||||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
|
||||||
import {
|
|
||||||
getLeftSidebarAnimationTargets,
|
|
||||||
MOBILE_PANEL_STATE_AGENT,
|
|
||||||
MOBILE_PANEL_STATE_AGENT_LIST_CLOSING,
|
|
||||||
MOBILE_PANEL_STATE_AGENT_LIST_OPEN,
|
|
||||||
MOBILE_PANEL_STATE_AGENT_LIST_OPENING,
|
|
||||||
MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING,
|
|
||||||
MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN,
|
|
||||||
MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING,
|
|
||||||
MOBILE_PANEL_TARGET_AGENT,
|
|
||||||
MOBILE_PANEL_TARGET_AGENT_LIST,
|
|
||||||
MOBILE_PANEL_TARGET_FILE_EXPLORER,
|
|
||||||
shouldSettleMobilePanelTransition,
|
|
||||||
shouldSyncSidebarAnimation,
|
|
||||||
} from "@/utils/sidebar-animation-state";
|
|
||||||
|
|
||||||
const ANIMATION_DURATION = 220;
|
|
||||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
|
||||||
// Keeps the overlay displayed long enough for the close animation to finish
|
|
||||||
// before React hides it.
|
|
||||||
const OVERLAY_CLOSE_GRACE_MS = 300;
|
|
||||||
export const MOBILE_VISUAL_PANEL_AGENT = 0;
|
|
||||||
export const MOBILE_VISUAL_PANEL_AGENT_LIST = 1;
|
|
||||||
export const MOBILE_VISUAL_PANEL_FILE_EXPLORER = 2;
|
|
||||||
|
|
||||||
interface SidebarAnimationContextValue {
|
|
||||||
translateX: SharedValue<number>;
|
|
||||||
backdropOpacity: SharedValue<number>;
|
|
||||||
windowWidth: number;
|
|
||||||
animateToOpen: () => void;
|
|
||||||
animateToClose: () => void;
|
|
||||||
startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
|
|
||||||
settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
|
|
||||||
overlayVisible: boolean;
|
|
||||||
setOverlayPeek: (peek: boolean) => void;
|
|
||||||
isGesturing: SharedValue<boolean>;
|
|
||||||
mobileVisualPanel: SharedValue<number>;
|
|
||||||
mobilePanelState: SharedValue<number>;
|
|
||||||
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
|
||||||
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
|
||||||
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
|
|
||||||
|
|
||||||
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
|
|
||||||
if (mobileView === "agent-list") {
|
|
||||||
return MOBILE_VISUAL_PANEL_AGENT_LIST;
|
|
||||||
}
|
|
||||||
if (mobileView === "file-explorer") {
|
|
||||||
return MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
|
||||||
}
|
|
||||||
return MOBILE_VISUAL_PANEL_AGENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSettledMobilePanelState(mobileView: "agent" | "agent-list" | "file-explorer"): number {
|
|
||||||
if (mobileView === "agent-list") {
|
|
||||||
return MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
|
|
||||||
}
|
|
||||||
if (mobileView === "file-explorer") {
|
|
||||||
return MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
|
|
||||||
}
|
|
||||||
return MOBILE_PANEL_STATE_AGENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
|
||||||
const isOpen = usePanelStore((state) =>
|
|
||||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initialize based on current state
|
|
||||||
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
|
||||||
const translateX = useSharedValue(initialTargets.translateX);
|
|
||||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
|
||||||
const isGesturing = useSharedValue(false);
|
|
||||||
const mobileVisualPanel = useSharedValue(getMobileVisualPanel(mobileView));
|
|
||||||
const mobilePanelState = useSharedValue(getSettledMobilePanelState(mobileView));
|
|
||||||
const mobilePanelTarget = useSharedValue(getMobileVisualPanel(mobileView));
|
|
||||||
const gestureAnimatingRef = useRef(false);
|
|
||||||
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
|
||||||
|
|
||||||
// React owns whether the overlay is displayed at all; the worklet owns its
|
|
||||||
// motion. On Fabric, Reanimated re-applies its own (possibly stale) animated
|
|
||||||
// props over React's committed props on every commit, so a settled-closed
|
|
||||||
// overlay can ghost back on screen after a heavy commit (reanimated#9635) —
|
|
||||||
// no committed transform/opacity value can prevent that. display lives on
|
|
||||||
// the plain wrapper View that Reanimated never touches, so React's value is
|
|
||||||
// authoritative: a hidden overlay stays hidden no matter what the animated
|
|
||||||
// props revert to. overlayPeek shows the overlay during an open gesture
|
|
||||||
// before the store flips; the close grace keeps it displayed while the
|
|
||||||
// close animation plays.
|
|
||||||
const [overlayPeek, setOverlayPeek] = useState(false);
|
|
||||||
const overlayTarget = isOpen || overlayPeek;
|
|
||||||
const [overlayVisible, setOverlayVisible] = useState(overlayTarget);
|
|
||||||
useEffect(() => {
|
|
||||||
if (overlayTarget) {
|
|
||||||
setOverlayVisible(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => setOverlayVisible(false), OVERLAY_CLOSE_GRACE_MS);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [overlayTarget]);
|
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
|
||||||
const prevIsOpen = useRef(isOpen);
|
|
||||||
const prevMobileView = useRef(mobileView);
|
|
||||||
const prevWindowWidth = useRef(windowWidth);
|
|
||||||
|
|
||||||
const startMobilePanelTransition = useCallback(
|
|
||||||
(nextMobileView: "agent" | "agent-list" | "file-explorer") => {
|
|
||||||
"worklet";
|
|
||||||
if (nextMobileView === "agent-list") {
|
|
||||||
mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT_LIST;
|
|
||||||
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPENING;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nextMobileView === "file-explorer") {
|
|
||||||
mobilePanelTarget.value = MOBILE_PANEL_TARGET_FILE_EXPLORER;
|
|
||||||
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT;
|
|
||||||
if (mobilePanelState.value === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN) {
|
|
||||||
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mobilePanelState.value === MOBILE_PANEL_STATE_AGENT_LIST_OPEN) {
|
|
||||||
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_CLOSING;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT;
|
|
||||||
},
|
|
||||||
[mobilePanelState, mobilePanelTarget],
|
|
||||||
);
|
|
||||||
|
|
||||||
const settleMobilePanel = useCallback(
|
|
||||||
(nextMobileView: "agent" | "agent-list" | "file-explorer") => {
|
|
||||||
"worklet";
|
|
||||||
if (nextMobileView === "agent-list") {
|
|
||||||
if (
|
|
||||||
!shouldSettleMobilePanelTransition(
|
|
||||||
mobilePanelTarget.value,
|
|
||||||
MOBILE_PANEL_TARGET_AGENT_LIST,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
|
|
||||||
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nextMobileView === "file-explorer") {
|
|
||||||
if (
|
|
||||||
!shouldSettleMobilePanelTransition(
|
|
||||||
mobilePanelTarget.value,
|
|
||||||
MOBILE_PANEL_TARGET_FILE_EXPLORER,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
|
||||||
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!shouldSettleMobilePanelTransition(mobilePanelTarget.value, MOBILE_PANEL_TARGET_AGENT)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
|
||||||
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT;
|
|
||||||
},
|
|
||||||
[mobileVisualPanel, mobilePanelState, mobilePanelTarget],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
|
||||||
useEffect(() => {
|
|
||||||
const didStateChange = shouldSyncSidebarAnimation({
|
|
||||||
previousIsOpen: prevIsOpen.current,
|
|
||||||
nextIsOpen: isOpen,
|
|
||||||
previousWindowWidth: prevWindowWidth.current,
|
|
||||||
nextWindowWidth: windowWidth,
|
|
||||||
});
|
|
||||||
const didMobileViewChange = prevMobileView.current !== mobileView;
|
|
||||||
const previousIsOpen = prevIsOpen.current;
|
|
||||||
const previousMobileView = prevMobileView.current;
|
|
||||||
const ownsMobileViewChange = previousMobileView === "agent-list" || mobileView === "agent-list";
|
|
||||||
prevIsOpen.current = isOpen;
|
|
||||||
prevMobileView.current = mobileView;
|
|
||||||
prevWindowWidth.current = windowWidth;
|
|
||||||
const didOpen = !previousIsOpen && isOpen;
|
|
||||||
|
|
||||||
if (!didStateChange && !didMobileViewChange) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (didOpen && isCompactLayout && isNative) {
|
|
||||||
Keyboard.dismiss();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gesture onEnd already started the animation on the UI thread — skip to avoid
|
|
||||||
// a second competing withTiming that can desync translateX and backdropOpacity
|
|
||||||
// after a provider remount (e.g. theme change).
|
|
||||||
if (gestureAnimatingRef.current) {
|
|
||||||
gestureAnimatingRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
|
||||||
if (isGesturing.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
|
||||||
|
|
||||||
if (previousIsOpen !== isOpen) {
|
|
||||||
if (isOpen) {
|
|
||||||
if (isCompactLayout) {
|
|
||||||
startMobilePanelTransition("agent-list");
|
|
||||||
}
|
|
||||||
translateX.value = withTiming(
|
|
||||||
targets.translateX,
|
|
||||||
{
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (!finished) return;
|
|
||||||
if (isCompactLayout) {
|
|
||||||
settleMobilePanel("agent-list");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCompactLayout && mobileView === "agent") {
|
|
||||||
startMobilePanelTransition("agent");
|
|
||||||
}
|
|
||||||
translateX.value = withTiming(
|
|
||||||
targets.translateX,
|
|
||||||
{
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (!finished) return;
|
|
||||||
if (isCompactLayout && mobileView === "agent") {
|
|
||||||
settleMobilePanel("agent");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
translateX.value = targets.translateX;
|
|
||||||
backdropOpacity.value = targets.backdropOpacity;
|
|
||||||
if (isCompactLayout && ownsMobileViewChange) {
|
|
||||||
settleMobilePanel(mobileView);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isOpen,
|
|
||||||
mobileView,
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
isGesturing,
|
|
||||||
isCompactLayout,
|
|
||||||
mobileVisualPanel,
|
|
||||||
mobilePanelState,
|
|
||||||
startMobilePanelTransition,
|
|
||||||
settleMobilePanel,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const animateToOpen = useCallback(() => {
|
|
||||||
"worklet";
|
|
||||||
startMobilePanelTransition("agent-list");
|
|
||||||
translateX.value = withTiming(
|
|
||||||
0,
|
|
||||||
{
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (!finished) return;
|
|
||||||
settleMobilePanel("agent-list");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
backdropOpacity.value = withTiming(1, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
|
|
||||||
|
|
||||||
const animateToClose = useCallback(() => {
|
|
||||||
"worklet";
|
|
||||||
startMobilePanelTransition("agent");
|
|
||||||
translateX.value = withTiming(
|
|
||||||
-windowWidth,
|
|
||||||
{
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (!finished) return;
|
|
||||||
settleMobilePanel("agent");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
backdropOpacity.value = withTiming(0, {
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
easing: ANIMATION_EASING,
|
|
||||||
});
|
|
||||||
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
|
|
||||||
|
|
||||||
const value = useMemo<SidebarAnimationContextValue>(
|
|
||||||
() => ({
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
startMobilePanelTransition,
|
|
||||||
settleMobilePanel,
|
|
||||||
overlayVisible,
|
|
||||||
setOverlayPeek,
|
|
||||||
isGesturing,
|
|
||||||
mobileVisualPanel,
|
|
||||||
mobilePanelState,
|
|
||||||
gestureAnimatingRef,
|
|
||||||
openGestureRef,
|
|
||||||
closeGestureRef,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
startMobilePanelTransition,
|
|
||||||
settleMobilePanel,
|
|
||||||
overlayVisible,
|
|
||||||
isGesturing,
|
|
||||||
mobileVisualPanel,
|
|
||||||
mobilePanelState,
|
|
||||||
gestureAnimatingRef,
|
|
||||||
openGestureRef,
|
|
||||||
closeGestureRef,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarAnimationContext.Provider value={value}>{children}</SidebarAnimationContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSidebarAnimation() {
|
|
||||||
const context = useContext(SidebarAnimationContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useSidebarAnimation must be used within SidebarAnimationProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
|
|||||||
title: "Cline",
|
title: "Cline",
|
||||||
description:
|
description:
|
||||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||||
version: "3.0.38",
|
version: "3.0.39",
|
||||||
iconId: "cline",
|
iconId: "cline",
|
||||||
installLink: "https://cline.bot/cli",
|
installLink: "https://cline.bot/cli",
|
||||||
command: ["npx", "-y", "cline@3.0.38", "--acp"],
|
command: ["npx", "-y", "cline@3.0.39", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "codebuddy-code",
|
id: "codebuddy-code",
|
||||||
title: "Codebuddy Code",
|
title: "Codebuddy Code",
|
||||||
description: "Tencent Cloud's official intelligent coding tool",
|
description: "Tencent Cloud's official intelligent coding tool",
|
||||||
version: "2.117.2",
|
version: "2.119.2",
|
||||||
iconId: "codebuddy-code",
|
iconId: "codebuddy-code",
|
||||||
installLink: "https://www.codebuddy.cn/cli/",
|
installLink: "https://www.codebuddy.cn/cli/",
|
||||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.117.2", "--acp"],
|
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.119.2", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "codewhale",
|
id: "codewhale",
|
||||||
@@ -122,10 +122,10 @@ const CATALOG_DATA = [
|
|||||||
id: "deepagents",
|
id: "deepagents",
|
||||||
title: "DeepAgents",
|
title: "DeepAgents",
|
||||||
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
|
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
|
||||||
version: "0.1.15",
|
version: "0.1.17",
|
||||||
iconId: "deepagents",
|
iconId: "deepagents",
|
||||||
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
||||||
command: ["npx", "-y", "deepagents-acp@0.1.15"],
|
command: ["npx", "-y", "deepagents-acp@0.1.17"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "devin",
|
id: "devin",
|
||||||
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
|
|||||||
id: "dimcode",
|
id: "dimcode",
|
||||||
title: "DimCode",
|
title: "DimCode",
|
||||||
description: "A coding agent that puts leading models at your command.",
|
description: "A coding agent that puts leading models at your command.",
|
||||||
version: "0.2.22",
|
version: "0.2.27",
|
||||||
iconId: "dimcode",
|
iconId: "dimcode",
|
||||||
installLink: "https://dimcode.dev/docs/acp.html",
|
installLink: "https://dimcode.dev/docs/acp.html",
|
||||||
command: ["npx", "-y", "dimcode@0.2.22", "acp"],
|
command: ["npx", "-y", "dimcode@0.2.27", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "dirac",
|
id: "dirac",
|
||||||
title: "Dirac",
|
title: "Dirac",
|
||||||
description:
|
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.",
|
"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.13",
|
version: "0.4.15",
|
||||||
iconId: "dirac",
|
iconId: "dirac",
|
||||||
installLink: "https://dirac.run",
|
installLink: "https://dirac.run",
|
||||||
command: ["npx", "-y", "dirac-cli@0.4.13", "--acp"],
|
command: ["npx", "-y", "dirac-cli@0.4.15", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "factory-droid",
|
id: "factory-droid",
|
||||||
title: "Factory Droid",
|
title: "Factory Droid",
|
||||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||||
version: "0.164.1",
|
version: "0.170.0",
|
||||||
iconId: "factory-droid",
|
iconId: "factory-droid",
|
||||||
installLink: "https://factory.ai/product/cli",
|
installLink: "https://factory.ai/product/cli",
|
||||||
command: ["npx", "-y", "droid@0.164.1", "exec", "--output-format", "acp-daemon"],
|
command: ["npx", "-y", "droid@0.170.0", "exec", "--output-format", "acp-daemon"],
|
||||||
env: {
|
env: {
|
||||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||||
@@ -173,19 +173,19 @@ const CATALOG_DATA = [
|
|||||||
id: "fast-agent",
|
id: "fast-agent",
|
||||||
title: "fast-agent",
|
title: "fast-agent",
|
||||||
description: "Code and build agents with comprehensive multi-provider support",
|
description: "Code and build agents with comprehensive multi-provider support",
|
||||||
version: "0.9.2",
|
version: "0.9.5",
|
||||||
iconId: "fast-agent",
|
iconId: "fast-agent",
|
||||||
installLink: "https://fast-agent.ai/acp/",
|
installLink: "https://fast-agent.ai/acp/",
|
||||||
command: ["uvx", "--from", "fast-agent-acp==0.9.2", "fast-agent-acp", "-x"],
|
command: ["uvx", "--from", "fast-agent-acp==0.9.5", "fast-agent-acp", "-x"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gemini",
|
id: "gemini",
|
||||||
title: "Gemini CLI",
|
title: "Gemini CLI",
|
||||||
description: "Google's official CLI for Gemini",
|
description: "Google's official CLI for Gemini",
|
||||||
version: "0.49.0",
|
version: "0.50.0",
|
||||||
iconId: "gemini",
|
iconId: "gemini",
|
||||||
installLink: "https://geminicli.com",
|
installLink: "https://geminicli.com",
|
||||||
command: ["npx", "-y", "@google/gemini-cli@0.49.0", "--acp"],
|
command: ["npx", "-y", "@google/gemini-cli@0.50.0", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "glm-acp-agent",
|
id: "glm-acp-agent",
|
||||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
|||||||
id: "qoder",
|
id: "qoder",
|
||||||
title: "Qoder CLI",
|
title: "Qoder CLI",
|
||||||
description: "AI coding assistant with agentic capabilities",
|
description: "AI coding assistant with agentic capabilities",
|
||||||
version: "1.0.39",
|
version: "1.0.43",
|
||||||
iconId: "qoder",
|
iconId: "qoder",
|
||||||
installLink: "https://qoder.com",
|
installLink: "https://qoder.com",
|
||||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.39", "--acp"],
|
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.43", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "qwen-code",
|
id: "qwen-code",
|
||||||
title: "Qwen Code",
|
title: "Qwen Code",
|
||||||
description: "Alibaba's Qwen coding assistant",
|
description: "Alibaba's Qwen coding assistant",
|
||||||
version: "0.19.7",
|
version: "0.19.9",
|
||||||
iconId: "qwen-code",
|
iconId: "qwen-code",
|
||||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.7", "--acp", "--experimental-skills"],
|
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.9", "--acp", "--experimental-skills"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sigit",
|
id: "sigit",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "./host";
|
||||||
|
import { pickDirectory } from "./pick-directory";
|
||||||
|
|
||||||
|
describe("pickDirectory", () => {
|
||||||
|
it("opens a single-directory picker and returns the selection", async () => {
|
||||||
|
const recordedOptions: DesktopDialogOpenOptions[] = [];
|
||||||
|
const dialog: DesktopDialogBridge = {
|
||||||
|
open: async (options) => {
|
||||||
|
if (options) {
|
||||||
|
recordedOptions.push(options);
|
||||||
|
}
|
||||||
|
return "/repo/project";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(pickDirectory(dialog)).resolves.toBe("/repo/project");
|
||||||
|
expect(recordedOptions).toEqual([
|
||||||
|
{
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the picker is cancelled", async () => {
|
||||||
|
const dialog: DesktopDialogBridge = {
|
||||||
|
open: async () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(pickDirectory(dialog)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { getDesktopHost, type DesktopDialogBridge } from "./host";
|
||||||
|
|
||||||
|
export async function pickDirectory(
|
||||||
|
dialog: DesktopDialogBridge | null = getDesktopHost()?.dialog ?? null,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const open = dialog?.open;
|
||||||
|
if (typeof open !== "function") {
|
||||||
|
throw new Error("Desktop dialog open() is unavailable in this environment.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
if (selection === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof selection === "string") {
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unexpected directory picker response.");
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ function GitActionMenuItem({
|
|||||||
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
|
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
|
||||||
const trailing = useMemo(
|
const trailing = useMemo(
|
||||||
() =>
|
() =>
|
||||||
action.id === "archive-worktree" && archiveShortcutKeys ? (
|
action.id === "archive-workspace" && archiveShortcutKeys ? (
|
||||||
<Shortcut chord={archiveShortcutKeys} />
|
<Shortcut chord={archiveShortcutKeys} />
|
||||||
) : undefined,
|
) : undefined,
|
||||||
[action.id, archiveShortcutKeys],
|
[action.id, archiveShortcutKeys],
|
||||||
@@ -58,7 +58,7 @@ function GitActionMenuItem({
|
|||||||
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
|
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
testID={
|
testID={
|
||||||
action.id === "archive-worktree"
|
action.id === "archive-workspace"
|
||||||
? "workspace-archive-action"
|
? "workspace-archive-action"
|
||||||
: `changes-menu-${action.id}`
|
: `changes-menu-${action.id}`
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||||
|
|
||||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||||
if (action.status === "pending") return action.pendingLabel;
|
if (action.status === "pending") return action.pendingLabel;
|
||||||
|
|||||||
@@ -2,16 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
|
||||||
import {
|
import {
|
||||||
__resetCheckoutGitActionsStoreForTests,
|
__resetCheckoutGitActionsStoreForTests,
|
||||||
isLocalWorktreeArchivePending,
|
|
||||||
useCheckoutGitActionsStore,
|
useCheckoutGitActionsStore,
|
||||||
} from "@/git/actions-store";
|
} from "@/git/actions-store";
|
||||||
import {
|
|
||||||
clearWorkspaceArchivePending,
|
|
||||||
isWorkspaceArchivePending,
|
|
||||||
} from "@/contexts/session-workspace-upserts";
|
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
default: {
|
default: {
|
||||||
@@ -31,33 +25,13 @@ function createDeferred<T>() {
|
|||||||
return { promise, resolve, reject };
|
return { promise, resolve, reject };
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">) {
|
|
||||||
return {
|
|
||||||
id: input.id,
|
|
||||||
projectId: input.projectId ?? "project-1",
|
|
||||||
projectDisplayName: input.projectDisplayName ?? "Project",
|
|
||||||
projectRootPath: input.projectRootPath ?? "/tmp/repo",
|
|
||||||
workspaceDirectory: input.workspaceDirectory ?? "/tmp/repo/worktrees/feature",
|
|
||||||
projectKind: input.projectKind ?? "git",
|
|
||||||
workspaceKind: input.workspaceKind ?? "worktree",
|
|
||||||
name: input.name ?? input.id,
|
|
||||||
status: input.status ?? "done",
|
|
||||||
archivingAt: input.archivingAt ?? null,
|
|
||||||
statusEnteredAt: null,
|
|
||||||
diffStat: input.diffStat ?? null,
|
|
||||||
scripts: input.scripts ?? [],
|
|
||||||
} satisfies WorkspaceDescriptor;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("checkout-git-actions-store", () => {
|
describe("checkout-git-actions-store", () => {
|
||||||
const serverId = "server-1";
|
const serverId = "server-1";
|
||||||
const cwd = "/tmp/repo/worktrees/feature";
|
const cwd = "/tmp/repo/worktrees/feature";
|
||||||
const workspaceId = "ws-feature";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
__resetCheckoutGitActionsStoreForTests();
|
__resetCheckoutGitActionsStoreForTests();
|
||||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
|
||||||
appQueryClient.clear();
|
appQueryClient.clear();
|
||||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||||
});
|
});
|
||||||
@@ -65,7 +39,6 @@ describe("checkout-git-actions-store", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
__resetCheckoutGitActionsStoreForTests();
|
__resetCheckoutGitActionsStoreForTests();
|
||||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
|
||||||
appQueryClient.clear();
|
appQueryClient.clear();
|
||||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||||
});
|
});
|
||||||
@@ -294,118 +267,4 @@ describe("checkout-git-actions-store", () => {
|
|||||||
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
||||||
).toBe("idle");
|
).toBe("idle");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
|
|
||||||
const deferred = createDeferred<Record<string, never>>();
|
|
||||||
const client = {
|
|
||||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
|
||||||
};
|
|
||||||
const featureWorkspace = workspace({
|
|
||||||
id: workspaceId,
|
|
||||||
name: "feature",
|
|
||||||
workspaceDirectory: cwd,
|
|
||||||
});
|
|
||||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
|
||||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
|
||||||
appQueryClient.setQueryData(
|
|
||||||
["sidebarPaseoWorktreeList", serverId, "/tmp"],
|
|
||||||
[{ worktreePath: cwd }, { worktreePath: "/tmp/other" }],
|
|
||||||
);
|
|
||||||
|
|
||||||
const archive = useCheckoutGitActionsStore
|
|
||||||
.getState()
|
|
||||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId });
|
|
||||||
|
|
||||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
|
|
||||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
|
|
||||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
|
|
||||||
{ worktreePath: "/tmp/other" },
|
|
||||||
]);
|
|
||||||
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
|
|
||||||
|
|
||||||
deferred.resolve({});
|
|
||||||
await archive;
|
|
||||||
|
|
||||||
expect(
|
|
||||||
isWorkspaceArchivePending({
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
isWorkspaceArchivePending({
|
|
||||||
serverId,
|
|
||||||
workspaceId: cwd,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("archives on the server even when its workspace cannot be resolved", async () => {
|
|
||||||
const client = {
|
|
||||||
archivePaseoWorktree: vi.fn(async () => ({})),
|
|
||||||
};
|
|
||||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
|
||||||
|
|
||||||
await useCheckoutGitActionsStore
|
|
||||||
.getState()
|
|
||||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
|
||||||
|
|
||||||
// The server archive is keyed by worktreePath and must run regardless.
|
|
||||||
expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd });
|
|
||||||
// The optimistic client-side mark is never keyed by the path.
|
|
||||||
expect(isWorkspaceArchivePending({ serverId, workspaceId: cwd })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restores an optimistically hidden worktree when archive fails", async () => {
|
|
||||||
const client = {
|
|
||||||
archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })),
|
|
||||||
};
|
|
||||||
const featureWorkspace = workspace({
|
|
||||||
id: workspaceId,
|
|
||||||
name: "feature",
|
|
||||||
workspaceDirectory: cwd,
|
|
||||||
});
|
|
||||||
const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }];
|
|
||||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
|
||||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
|
||||||
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
useCheckoutGitActionsStore
|
|
||||||
.getState()
|
|
||||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId }),
|
|
||||||
).rejects.toThrow("archive failed");
|
|
||||||
|
|
||||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(
|
|
||||||
featureWorkspace,
|
|
||||||
);
|
|
||||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual(
|
|
||||||
listSnapshot,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports local archive pending only while the archive action is in flight", async () => {
|
|
||||||
const deferred = createDeferred<Record<string, never>>();
|
|
||||||
const client = {
|
|
||||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
|
||||||
};
|
|
||||||
const featureWorkspace = workspace({
|
|
||||||
id: workspaceId,
|
|
||||||
name: "feature",
|
|
||||||
workspaceDirectory: cwd,
|
|
||||||
});
|
|
||||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
|
||||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
|
||||||
|
|
||||||
const archive = useCheckoutGitActionsStore
|
|
||||||
.getState()
|
|
||||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
|
||||||
|
|
||||||
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
|
|
||||||
|
|
||||||
deferred.resolve({});
|
|
||||||
await archive;
|
|
||||||
|
|
||||||
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import type { QueryKey } from "@tanstack/react-query";
|
|
||||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||||
import {
|
|
||||||
buildWorkspaceTabPersistenceKey,
|
|
||||||
useWorkspaceLayoutStore,
|
|
||||||
} from "@/stores/workspace-layout-store";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
|
||||||
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
|
|
||||||
import {
|
|
||||||
clearWorkspaceArchivePending,
|
|
||||||
markWorkspaceArchivePending,
|
|
||||||
} from "@/contexts/session-workspace-upserts";
|
|
||||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
|
||||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
|
|
||||||
@@ -36,8 +24,7 @@ export type CheckoutGitAsyncActionId =
|
|||||||
| "enable-pr-auto-merge-rebase"
|
| "enable-pr-auto-merge-rebase"
|
||||||
| "disable-pr-auto-merge"
|
| "disable-pr-auto-merge"
|
||||||
| "merge-branch"
|
| "merge-branch"
|
||||||
| "merge-from-base"
|
| "merge-from-base";
|
||||||
| "archive-worktree";
|
|
||||||
|
|
||||||
type CheckoutKey = string;
|
type CheckoutKey = string;
|
||||||
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
|
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
|
||||||
@@ -89,119 +76,6 @@ function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
|
|||||||
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
|
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateWorktreeList() {
|
|
||||||
void appQueryClient.invalidateQueries({
|
|
||||||
predicate: (query) =>
|
|
||||||
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
|
|
||||||
});
|
|
||||||
void appQueryClient.invalidateQueries({
|
|
||||||
predicate: (query) =>
|
|
||||||
Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath: string }): void {
|
|
||||||
const serverId = input.serverId.trim();
|
|
||||||
const worktreePath = input.worktreePath.trim();
|
|
||||||
if (!serverId || !worktreePath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeFromList = (current: unknown) => {
|
|
||||||
if (!Array.isArray(current)) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
const filtered = current.filter((entry) => entry?.worktreePath !== worktreePath);
|
|
||||||
return filtered.length === current.length ? current : filtered;
|
|
||||||
};
|
|
||||||
|
|
||||||
appQueryClient.setQueriesData(
|
|
||||||
{
|
|
||||||
predicate: (query) =>
|
|
||||||
Array.isArray(query.queryKey) &&
|
|
||||||
query.queryKey[0] === "paseoWorktreeList" &&
|
|
||||||
query.queryKey[1] === serverId,
|
|
||||||
},
|
|
||||||
removeFromList,
|
|
||||||
);
|
|
||||||
|
|
||||||
appQueryClient.setQueriesData(
|
|
||||||
{
|
|
||||||
predicate: (query) =>
|
|
||||||
Array.isArray(query.queryKey) &&
|
|
||||||
query.queryKey[0] === "sidebarPaseoWorktreeList" &&
|
|
||||||
query.queryKey[1] === serverId,
|
|
||||||
},
|
|
||||||
removeFromList,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorktreeArchiveSnapshot {
|
|
||||||
workspace: WorkspaceDescriptor | null;
|
|
||||||
worktreeLists: Array<[QueryKey, unknown]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWorktreeListQuery(input: { queryKey: QueryKey; serverId: string }): boolean {
|
|
||||||
return (
|
|
||||||
Array.isArray(input.queryKey) &&
|
|
||||||
(input.queryKey[0] === "paseoWorktreeList" ||
|
|
||||||
input.queryKey[0] === "sidebarPaseoWorktreeList") &&
|
|
||||||
input.queryKey[1] === input.serverId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotWorktreeArchiveState(input: {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string | undefined;
|
|
||||||
}): WorktreeArchiveSnapshot {
|
|
||||||
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
|
|
||||||
const workspaceKey = input.workspaceId
|
|
||||||
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: input.workspaceId })
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
|
|
||||||
worktreeLists: appQueryClient.getQueriesData({
|
|
||||||
predicate: (query) =>
|
|
||||||
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeWorktreeFromSessionStore(input: { serverId: string; workspaceId: string }): void {
|
|
||||||
const serverId = input.serverId.trim();
|
|
||||||
const workspaceId = input.workspaceId.trim();
|
|
||||||
if (!serverId || !workspaceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
useSessionStore.getState().removeWorkspace(serverId, workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreWorktreeArchiveState(input: {
|
|
||||||
serverId: string;
|
|
||||||
snapshot: WorktreeArchiveSnapshot;
|
|
||||||
}): void {
|
|
||||||
if (input.snapshot.workspace) {
|
|
||||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [queryKey, data] of input.snapshot.worktreeLists) {
|
|
||||||
appQueryClient.setQueryData(queryKey, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
|
|
||||||
const serverId = input.serverId.trim();
|
|
||||||
const workspaceId = input.workspaceId.trim();
|
|
||||||
if (!serverId || !workspaceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
|
||||||
if (workspaceKey) {
|
|
||||||
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
|
|
||||||
}
|
|
||||||
useWorkspaceTabsStore.getState().purgeWorkspace({ serverId, workspaceId });
|
|
||||||
}
|
|
||||||
|
|
||||||
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
const inFlight = new Map<string, Promise<unknown>>();
|
const inFlight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
@@ -209,16 +83,6 @@ function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): stri
|
|||||||
return `${key}::${actionId}`;
|
return `${key}::${actionId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLocalWorktreeArchivePending(input: { serverId: string; cwd: string }): boolean {
|
|
||||||
return (
|
|
||||||
useCheckoutGitActionsStore.getState().getStatus({
|
|
||||||
serverId: input.serverId,
|
|
||||||
cwd: input.cwd,
|
|
||||||
actionId: "archive-worktree",
|
|
||||||
}) === "pending"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CheckoutGitActionsStoreState {
|
interface CheckoutGitActionsStoreState {
|
||||||
statusByCheckout: Record<CheckoutKey, StatusMap>;
|
statusByCheckout: Record<CheckoutKey, StatusMap>;
|
||||||
|
|
||||||
@@ -247,12 +111,6 @@ interface CheckoutGitActionsStoreState {
|
|||||||
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
|
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||||
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||||
archiveWorktree: (params: {
|
|
||||||
serverId: string;
|
|
||||||
cwd: string;
|
|
||||||
worktreePath: string;
|
|
||||||
workspaceId?: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCheckoutAction({
|
async function runCheckoutAction({
|
||||||
@@ -490,50 +348,6 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId }) => {
|
|
||||||
await runCheckoutAction({
|
|
||||||
serverId,
|
|
||||||
cwd,
|
|
||||||
actionId: "archive-worktree",
|
|
||||||
run: async () => {
|
|
||||||
const client = resolveClient(serverId);
|
|
||||||
const snapshot = snapshotWorktreeArchiveState({ serverId, workspaceId });
|
|
||||||
// The server archive is keyed by worktreePath and must always run. The
|
|
||||||
// optimistic client-side updates are keyed by workspace id, so they only
|
|
||||||
// apply when the caller passes the workspace id and it resolves in the
|
|
||||||
// local store.
|
|
||||||
const workspace = snapshot.workspace;
|
|
||||||
if (workspace) {
|
|
||||||
markWorkspaceArchivePending({
|
|
||||||
serverId,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
});
|
|
||||||
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
|
|
||||||
}
|
|
||||||
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
|
||||||
try {
|
|
||||||
const payload = await client.archivePaseoWorktree({
|
|
||||||
worktreePath,
|
|
||||||
...(workspaceId !== undefined ? { workspaceId } : {}),
|
|
||||||
});
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error.message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (workspace) {
|
|
||||||
clearWorkspaceArchivePending({ serverId, workspaceId: workspace.id });
|
|
||||||
}
|
|
||||||
restoreWorktreeArchiveState({ serverId, snapshot });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
invalidateWorktreeList();
|
|
||||||
if (workspace) {
|
|
||||||
purgeArchivedWorkspaceState({ serverId, workspaceId: workspace.id });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function __resetCheckoutGitActionsStoreForTests() {
|
export function __resetCheckoutGitActionsStoreForTests() {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
handler: () => undefined,
|
handler: () => undefined,
|
||||||
},
|
},
|
||||||
"archive-worktree": {
|
"archive-workspace": {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
handler: () => undefined,
|
handler: () => undefined,
|
||||||
@@ -140,7 +140,13 @@ describe("git-actions-policy", () => {
|
|||||||
it("shows only remote sync actions on the base branch", () => {
|
it("shows only remote sync actions on the base branch", () => {
|
||||||
const actions = buildGitActions(createInput({ hasRemote: true }));
|
const actions = buildGitActions(createInput({ hasRemote: true }));
|
||||||
|
|
||||||
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push", "pull-and-push"]);
|
expect(actions.primary).toBeNull();
|
||||||
|
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||||
|
"pull",
|
||||||
|
"push",
|
||||||
|
"pull-and-push",
|
||||||
|
"archive-workspace",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prioritizes pull when the branch is behind origin", () => {
|
it("prioritizes pull when the branch is behind origin", () => {
|
||||||
@@ -272,6 +278,7 @@ describe("git-actions-policy", () => {
|
|||||||
"merge-pr-squash",
|
"merge-pr-squash",
|
||||||
"merge-pr-merge",
|
"merge-pr-merge",
|
||||||
"merge-pr-rebase",
|
"merge-pr-rebase",
|
||||||
|
"archive-workspace",
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
||||||
@@ -352,12 +359,33 @@ describe("git-actions-policy", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only shows archive worktree for paseo worktrees", () => {
|
it("hides Git actions for a non-Git workspace", () => {
|
||||||
const hidden = buildGitActions(createInput());
|
const directory = buildGitActions(createInput({ isGit: false }));
|
||||||
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
|
||||||
|
|
||||||
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
|
expect(directory).toEqual({ primary: null, secondary: [], menu: [] });
|
||||||
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
|
});
|
||||||
|
|
||||||
|
it("offers archive workspace for Git checkouts and worktrees", () => {
|
||||||
|
const localCheckout = buildGitActions(createInput({ hasUncommittedChanges: true }));
|
||||||
|
const worktree = buildGitActions(
|
||||||
|
createInput({ hasUncommittedChanges: true, isPaseoOwnedWorktree: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(localCheckout.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
|
||||||
|
expect(worktree.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not promote archive to primary for an idle regular Git checkout", () => {
|
||||||
|
const actions = buildGitActions(createInput());
|
||||||
|
|
||||||
|
expect(actions.primary).toBeNull();
|
||||||
|
expect(actions.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still promotes archive as primary for an idle Paseo-owned worktree", () => {
|
||||||
|
const actions = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||||
|
|
||||||
|
expect(actions.primary).toMatchObject({ id: "archive-workspace" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => {
|
it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => {
|
||||||
@@ -541,6 +569,7 @@ describe("git-actions-policy", () => {
|
|||||||
"merge-pr-squash",
|
"merge-pr-squash",
|
||||||
"merge-pr-merge",
|
"merge-pr-merge",
|
||||||
"merge-pr-rebase",
|
"merge-pr-rebase",
|
||||||
|
"archive-workspace",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -717,6 +746,7 @@ describe("git-actions-policy", () => {
|
|||||||
"merge-pr-squash",
|
"merge-pr-squash",
|
||||||
"merge-pr-merge",
|
"merge-pr-merge",
|
||||||
"merge-pr-rebase",
|
"merge-pr-rebase",
|
||||||
|
"archive-workspace",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -757,6 +787,7 @@ describe("git-actions-policy", () => {
|
|||||||
"merge-branch",
|
"merge-branch",
|
||||||
"pr",
|
"pr",
|
||||||
"enable-pr-auto-merge-squash",
|
"enable-pr-auto-merge-squash",
|
||||||
|
"archive-workspace",
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
actions.secondary.some((action) =>
|
actions.secondary.some((action) =>
|
||||||
@@ -899,6 +930,7 @@ describe("git-actions-policy", () => {
|
|||||||
"merge-branch",
|
"merge-branch",
|
||||||
"pr",
|
"pr",
|
||||||
"merge-pr-merge",
|
"merge-pr-merge",
|
||||||
|
"archive-workspace",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -952,7 +984,7 @@ describe("git-actions-policy", () => {
|
|||||||
.filter((action) => !action.startsGroup)
|
.filter((action) => !action.startsGroup)
|
||||||
.map((action) => action.id);
|
.map((action) => action.id);
|
||||||
|
|
||||||
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-worktree"]);
|
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-workspace"]);
|
||||||
expect(nonGroupStarters).toEqual([
|
expect(nonGroupStarters).toEqual([
|
||||||
"pull",
|
"pull",
|
||||||
"push",
|
"push",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export type GitActionId =
|
|||||||
| "disable-pr-auto-merge"
|
| "disable-pr-auto-merge"
|
||||||
| "merge-branch"
|
| "merge-branch"
|
||||||
| "merge-from-base"
|
| "merge-from-base"
|
||||||
| "archive-worktree";
|
| "archive-workspace";
|
||||||
|
|
||||||
export interface GitAction {
|
export interface GitAction {
|
||||||
id: GitActionId;
|
id: GitActionId;
|
||||||
@@ -282,20 +282,16 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
handler: input.runtime["merge-from-base"].handler,
|
handler: input.runtime["merge-from-base"].handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
allActions.set("archive-worktree", {
|
allActions.set("archive-workspace", {
|
||||||
id: "archive-worktree",
|
id: "archive-workspace",
|
||||||
label: i18n.t("workspace.git.actions.archive.label"),
|
label: i18n.t("workspace.git.actions.archive.label"),
|
||||||
pendingLabel: i18n.t("workspace.git.actions.archive.pending"),
|
pendingLabel: i18n.t("workspace.git.actions.archive.pending"),
|
||||||
successLabel: i18n.t("workspace.git.actions.archive.success"),
|
successLabel: i18n.t("workspace.git.actions.archive.success"),
|
||||||
disabled: input.runtime["archive-worktree"].disabled,
|
disabled: input.runtime["archive-workspace"].disabled,
|
||||||
status: input.runtime["archive-worktree"].status,
|
status: input.runtime["archive-workspace"].status,
|
||||||
unavailableMessage:
|
icon: input.runtime["archive-workspace"].icon,
|
||||||
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
|
|
||||||
? undefined
|
|
||||||
: i18n.t("workspace.git.actions.unavailable.archiveNotWorktree"),
|
|
||||||
icon: input.runtime["archive-worktree"].icon,
|
|
||||||
startsGroup: true,
|
startsGroup: true,
|
||||||
handler: input.runtime["archive-worktree"].handler,
|
handler: input.runtime["archive-workspace"].handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
const primaryActionId = getPrimaryActionId(input);
|
const primaryActionId = getPrimaryActionId(input);
|
||||||
@@ -305,20 +301,20 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
if (!input.isOnBaseBranch) {
|
if (!input.isOnBaseBranch) {
|
||||||
secondaryIds.push(...getFeatureActionIds(input));
|
secondaryIds.push(...getFeatureActionIds(input));
|
||||||
}
|
}
|
||||||
if (input.isPaseoOwnedWorktree) {
|
secondaryIds.push("archive-workspace");
|
||||||
secondaryIds.push("archive-worktree");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
primary,
|
primary,
|
||||||
secondary: secondaryIds.map((id) => allActions.get(id)!),
|
secondary: secondaryIds
|
||||||
|
.filter((id) => id !== "archive-workspace" || primaryActionId !== "archive-workspace")
|
||||||
|
.map((id) => allActions.get(id)!),
|
||||||
menu: [],
|
menu: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||||
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
|
if (input.shouldPromoteArchive) {
|
||||||
return "archive-worktree";
|
return "archive-workspace";
|
||||||
}
|
}
|
||||||
if (input.hasUncommittedChanges) {
|
if (input.hasUncommittedChanges) {
|
||||||
return "commit";
|
return "commit";
|
||||||
@@ -350,6 +346,13 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
|||||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||||
return "pr";
|
return "pr";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only Paseo-owned worktrees get Archive as a fallback primary action.
|
||||||
|
// Regular Git checkouts should not show the destructive archive CTA by default.
|
||||||
|
if (input.isPaseoOwnedWorktree) {
|
||||||
|
return "archive-workspace";
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
useActiveWorkspaceSelection,
|
useActiveWorkspaceSelection,
|
||||||
type ActiveWorkspaceSelection,
|
type ActiveWorkspaceSelection,
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||||
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
|
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
|
||||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||||
|
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||||
|
|
||||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||||
|
|
||||||
@@ -97,15 +98,12 @@ function extractGitCommitCounts(gitStatus: CheckoutStatusPayload | null): GitCom
|
|||||||
}
|
}
|
||||||
|
|
||||||
function computeShouldPromoteArchive(input: {
|
function computeShouldPromoteArchive(input: {
|
||||||
isPaseoOwnedWorktree: boolean;
|
|
||||||
hasUncommittedChanges: boolean;
|
hasUncommittedChanges: boolean;
|
||||||
postShipArchiveSuggested: boolean;
|
postShipArchiveSuggested: boolean;
|
||||||
isMergedPullRequest: boolean;
|
isMergedPullRequest: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return (
|
return (
|
||||||
input.isPaseoOwnedWorktree &&
|
!input.hasUncommittedChanges && (input.postShipArchiveSuggested || input.isMergedPullRequest)
|
||||||
!input.hasUncommittedChanges &&
|
|
||||||
(input.postShipArchiveSuggested || input.isMergedPullRequest)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +129,6 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
|
|||||||
isPaseoOwnedWorktree,
|
isPaseoOwnedWorktree,
|
||||||
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
|
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
|
||||||
shouldPromoteArchive: computeShouldPromoteArchive({
|
shouldPromoteArchive: computeShouldPromoteArchive({
|
||||||
isPaseoOwnedWorktree,
|
|
||||||
hasUncommittedChanges,
|
hasUncommittedChanges,
|
||||||
postShipArchiveSuggested,
|
postShipArchiveSuggested,
|
||||||
isMergedPullRequest,
|
isMergedPullRequest,
|
||||||
@@ -173,6 +170,52 @@ interface UseWorkspaceScreenArchiveControllerInput {
|
|||||||
t: (key: string, options?: Record<string, unknown>) => string;
|
t: (key: string, options?: Record<string, unknown>) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveArchiveWorkspaceDescriptor(input: {
|
||||||
|
workspaces: Map<string, WorkspaceDescriptor> | undefined;
|
||||||
|
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||||
|
workspaceDirectory: string | null | undefined;
|
||||||
|
}): WorkspaceDescriptor | null {
|
||||||
|
const activeWorkspaceKey = input.activeWorkspaceSelection
|
||||||
|
? resolveWorkspaceMapKeyByIdentity({
|
||||||
|
workspaces: input.workspaces,
|
||||||
|
workspaceId: input.activeWorkspaceSelection.workspaceId,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (activeWorkspaceKey) {
|
||||||
|
return input.workspaces?.get(activeWorkspaceKey) ?? null;
|
||||||
|
}
|
||||||
|
if (!input.workspaceDirectory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const candidate of input.workspaces?.values() ?? []) {
|
||||||
|
if (candidate.workspaceDirectory === input.workspaceDirectory) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkspaceArchiveRisk(
|
||||||
|
workspace: WorkspaceDescriptor | null,
|
||||||
|
gitStatus: CheckoutStatusPayload | null,
|
||||||
|
): { isDirty: boolean | null | undefined; aheadOfOrigin: number | null | undefined } {
|
||||||
|
return {
|
||||||
|
isDirty: gitStatus?.isDirty ?? workspace?.gitRuntime?.isDirty,
|
||||||
|
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? workspace?.gitRuntime?.aheadOfOrigin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canArchiveWorkspace(
|
||||||
|
workspace: WorkspaceDescriptor | null,
|
||||||
|
risk: ReturnType<typeof resolveWorkspaceArchiveRisk>,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
workspace !== null &&
|
||||||
|
(workspace.workspaceKind !== "worktree" ||
|
||||||
|
(risk.isDirty !== undefined && risk.aheadOfOrigin !== undefined))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function useWorkspaceScreenArchiveController({
|
function useWorkspaceScreenArchiveController({
|
||||||
serverId,
|
serverId,
|
||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
@@ -182,28 +225,28 @@ function useWorkspaceScreenArchiveController({
|
|||||||
t,
|
t,
|
||||||
}: UseWorkspaceScreenArchiveControllerInput) {
|
}: UseWorkspaceScreenArchiveControllerInput) {
|
||||||
const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
||||||
const archiveWorkspaceRecord = useMemo(() => {
|
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||||
if (!workspaceDirectory) {
|
const workspaceDescriptor = useMemo(
|
||||||
return null;
|
() =>
|
||||||
}
|
resolveArchiveWorkspaceDescriptor({
|
||||||
for (const candidate of sessionWorkspaces?.values() ?? []) {
|
workspaces: sessionWorkspaces,
|
||||||
if (candidate.workspaceDirectory === workspaceDirectory) {
|
activeWorkspaceSelection,
|
||||||
return candidate;
|
workspaceDirectory,
|
||||||
}
|
}),
|
||||||
}
|
[activeWorkspaceSelection, sessionWorkspaces, workspaceDirectory],
|
||||||
return null;
|
);
|
||||||
}, [sessionWorkspaces, workspaceDirectory]);
|
const archiveRisk = resolveWorkspaceArchiveRisk(workspaceDescriptor, gitStatus);
|
||||||
|
|
||||||
return useWorkspaceArchive({
|
const controller = useWorkspaceArchive({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: activeWorkspaceSelection?.workspaceId ?? archiveWorkspaceRecord?.id ?? "",
|
workspaceId: workspaceDescriptor?.id ?? "",
|
||||||
workspaceDirectory,
|
workspaceKind: workspaceDescriptor?.workspaceKind ?? "directory",
|
||||||
workspaceKind: gitStatus?.isPaseoOwnedWorktree ? "worktree" : "local_checkout",
|
name: workspaceDescriptor?.name ?? branchLabel,
|
||||||
name: archiveWorkspaceRecord?.name ?? branchLabel,
|
isDirty: archiveRisk.isDirty,
|
||||||
isDirty: gitStatus?.isDirty,
|
aheadOfOrigin: archiveRisk.aheadOfOrigin,
|
||||||
aheadOfOrigin: gitStatus?.aheadOfOrigin,
|
diffStat: workspaceDescriptor?.diffStat ?? null,
|
||||||
diffStat: archiveWorkspaceRecord?.diffStat ?? null,
|
|
||||||
warningLabels: getWorktreeArchiveWarningLabels(t),
|
warningLabels: getWorktreeArchiveWarningLabels(t),
|
||||||
|
onSetHiding: setIsHidingWorkspace,
|
||||||
onArchiveStarted: () => {
|
onArchiveStarted: () => {
|
||||||
if (!activeWorkspaceSelection) {
|
if (!activeWorkspaceSelection) {
|
||||||
return;
|
return;
|
||||||
@@ -215,6 +258,12 @@ function useWorkspaceScreenArchiveController({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...controller,
|
||||||
|
isArchiving: workspaceDescriptor?.archivingAt != null || isHidingWorkspace,
|
||||||
|
canArchive: canArchiveWorkspace(workspaceDescriptor, archiveRisk),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
||||||
@@ -342,9 +391,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
const mergeFromBaseStatus = useCheckoutGitActionsStore((s) =>
|
const mergeFromBaseStatus = useCheckoutGitActionsStore((s) =>
|
||||||
s.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
|
s.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
|
||||||
);
|
);
|
||||||
const archiveStatus = useCheckoutGitActionsStore((s) =>
|
|
||||||
s.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const runCommit = useCheckoutGitActionsStore((s) => s.commit);
|
const runCommit = useCheckoutGitActionsStore((s) => s.commit);
|
||||||
const runPull = useCheckoutGitActionsStore((s) => s.pull);
|
const runPull = useCheckoutGitActionsStore((s) => s.pull);
|
||||||
@@ -533,7 +579,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
t,
|
t,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleArchiveWorktree = useCallback(() => {
|
const handleArchiveWorkspace = useCallback(() => {
|
||||||
archiveController.archive();
|
archiveController.archive();
|
||||||
}, [archiveController]);
|
}, [archiveController]);
|
||||||
|
|
||||||
@@ -678,11 +724,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
icon: icons.mergeFromBase,
|
icon: icons.mergeFromBase,
|
||||||
handler: handleMergeFromBase,
|
handler: handleMergeFromBase,
|
||||||
},
|
},
|
||||||
"archive-worktree": {
|
"archive-workspace": {
|
||||||
disabled: isActionDisabled(actionsDisabled, archiveStatus),
|
disabled: !archiveController.canArchive || archiveController.isArchiving,
|
||||||
status: archiveStatus,
|
status: archiveController.isArchiving ? "pending" : "idle",
|
||||||
icon: icons.archive,
|
icon: icons.archive,
|
||||||
handler: handleArchiveWorktree,
|
handler: handleArchiveWorkspace,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -723,7 +769,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
disablePrAutoMergeStatus,
|
disablePrAutoMergeStatus,
|
||||||
mergeStatus,
|
mergeStatus,
|
||||||
mergeFromBaseStatus,
|
mergeFromBaseStatus,
|
||||||
archiveStatus,
|
archiveController.canArchive,
|
||||||
|
archiveController.isArchiving,
|
||||||
handleCommit,
|
handleCommit,
|
||||||
handlePull,
|
handlePull,
|
||||||
handlePush,
|
handlePush,
|
||||||
@@ -734,7 +781,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
handleDisablePrAutoMerge,
|
handleDisablePrAutoMerge,
|
||||||
handleMergeBranch,
|
handleMergeBranch,
|
||||||
handleMergeFromBase,
|
handleMergeFromBase,
|
||||||
handleArchiveWorktree,
|
handleArchiveWorkspace,
|
||||||
icons,
|
icons,
|
||||||
baseRef,
|
baseRef,
|
||||||
],
|
],
|
||||||
@@ -890,7 +937,7 @@ function getTranslatedGitActionLabels(
|
|||||||
pendingLabel: t("workspace.git.actions.mergeFromBase.pending"),
|
pendingLabel: t("workspace.git.actions.mergeFromBase.pending"),
|
||||||
successLabel: t("workspace.git.actions.mergeFromBase.success"),
|
successLabel: t("workspace.git.actions.mergeFromBase.success"),
|
||||||
};
|
};
|
||||||
case "archive-worktree":
|
case "archive-workspace":
|
||||||
return {
|
return {
|
||||||
label: t("workspace.git.actions.archive.label"),
|
label: t("workspace.git.actions.archive.label"),
|
||||||
pendingLabel: t("workspace.git.actions.archive.pending"),
|
pendingLabel: t("workspace.git.actions.archive.pending"),
|
||||||
@@ -945,8 +992,6 @@ function translateGitActionUnavailableMessage(
|
|||||||
"workspace.git.actions.unavailable.updateNoBase",
|
"workspace.git.actions.unavailable.updateNoBase",
|
||||||
"Update isn't available while you have local changes so commit or stash them first":
|
"Update isn't available while you have local changes so commit or stash them first":
|
||||||
"workspace.git.actions.unavailable.updateDirty",
|
"workspace.git.actions.unavailable.updateDirty",
|
||||||
"Archive isn't available here because this workspace was not created as a Paseo worktree":
|
|
||||||
"workspace.git.actions.unavailable.archiveNotWorktree",
|
|
||||||
"Merge PR isn't available right now because GitHub isn't connected":
|
"Merge PR isn't available right now because GitHub isn't connected":
|
||||||
"workspace.git.actions.unavailable.mergePrNoGithub",
|
"workspace.git.actions.unavailable.mergePrNoGithub",
|
||||||
"Merge PR isn't available because there isn't a pull request yet":
|
"Merge PR isn't available because there isn't a pull request yet":
|
||||||
@@ -979,7 +1024,7 @@ function getWorktreeArchiveWarningLabels(
|
|||||||
t: (key: string, options?: Record<string, unknown>) => string,
|
t: (key: string, options?: Record<string, unknown>) => string,
|
||||||
): WorktreeArchiveWarningLabels {
|
): WorktreeArchiveWarningLabels {
|
||||||
return {
|
return {
|
||||||
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
|
title: (workspaceName) => t("workspace.git.actions.archiveWarning.title", { workspaceName }),
|
||||||
confirm: t("workspace.git.actions.archiveWarning.confirm"),
|
confirm: t("workspace.git.actions.archiveWarning.confirm"),
|
||||||
cancel: t("workspace.git.actions.archiveWarning.cancel"),
|
cancel: t("workspace.git.actions.archiveWarning.cancel"),
|
||||||
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
|
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { GitActionsSplitButton } from "@/git/actions-split-button";
|
|||||||
import { useGitActions } from "@/git/use-actions";
|
import { useGitActions } from "@/git/use-actions";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
interface WorkspaceGitActionsProps {
|
interface WorkspaceActionsProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
hideLabels?: boolean;
|
hideLabels?: boolean;
|
||||||
@@ -47,16 +47,12 @@ const ICONS = {
|
|||||||
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
|
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) {
|
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
|
||||||
const { gitActions, isGit } = useGitActions({
|
const { gitActions } = useGitActions({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
icons: ICONS,
|
icons: ICONS,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isGit) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
|
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import {
|
|||||||
toWorktreeArchiveRisk,
|
toWorktreeArchiveRisk,
|
||||||
} from "@/git/worktree-archive-warning";
|
} from "@/git/worktree-archive-warning";
|
||||||
|
|
||||||
describe("worktree archive warning", () => {
|
describe("workspace archive warning for worktree backing", () => {
|
||||||
it("does not require a confirmation for clean and pushed worktrees", () => {
|
it("does not require a confirmation for clean and pushed worktrees", () => {
|
||||||
expect(
|
expect(
|
||||||
buildWorktreeArchiveConfirmationMessage({
|
buildWorktreeArchiveConfirmationMessage({
|
||||||
worktreeName: "feature",
|
workspaceName: "feature",
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
aheadOfOrigin: 0,
|
aheadOfOrigin: 0,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
@@ -51,7 +51,7 @@ describe("worktree archive warning", () => {
|
|||||||
it("includes every archive risk in the confirmation copy", () => {
|
it("includes every archive risk in the confirmation copy", () => {
|
||||||
expect(
|
expect(
|
||||||
buildWorktreeArchiveConfirmationMessage({
|
buildWorktreeArchiveConfirmationMessage({
|
||||||
worktreeName: "risky-feature",
|
workspaceName: "risky-feature",
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
aheadOfOrigin: 1,
|
aheadOfOrigin: 1,
|
||||||
diffStat: { additions: 1, deletions: 3 },
|
diffStat: { additions: 1, deletions: 3 },
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ export interface WorktreeArchiveRiskInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
|
export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
|
||||||
worktreeName: string;
|
workspaceName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorktreeArchiveWarningLabels {
|
export interface WorktreeArchiveWarningLabels {
|
||||||
title: (worktreeName: string) => string;
|
title: (workspaceName: string) => string;
|
||||||
confirm: string;
|
confirm: string;
|
||||||
cancel: string;
|
cancel: string;
|
||||||
uncommittedChanges: string;
|
uncommittedChanges: string;
|
||||||
@@ -29,7 +29,7 @@ export interface WorktreeArchiveWarningLabels {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabels = {
|
export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabels = {
|
||||||
title: (worktreeName) => i18n.t("workspace.git.actions.archiveWarning.title", { worktreeName }),
|
title: (workspaceName) => i18n.t("workspace.git.actions.archiveWarning.title", { workspaceName }),
|
||||||
confirm: i18n.t("workspace.git.actions.archiveWarning.confirm"),
|
confirm: i18n.t("workspace.git.actions.archiveWarning.confirm"),
|
||||||
cancel: i18n.t("workspace.git.actions.archiveWarning.cancel"),
|
cancel: i18n.t("workspace.git.actions.archiveWarning.cancel"),
|
||||||
uncommittedChanges: i18n.t("workspace.git.actions.archiveWarning.uncommittedChanges"),
|
uncommittedChanges: i18n.t("workspace.git.actions.archiveWarning.uncommittedChanges"),
|
||||||
@@ -123,7 +123,7 @@ export async function confirmRiskyWorktreeArchive(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await confirmDialog({
|
return await confirmDialog({
|
||||||
title: labels.title(input.worktreeName),
|
title: labels.title(input.workspaceName),
|
||||||
message,
|
message,
|
||||||
confirmLabel: labels.confirm,
|
confirmLabel: labels.confirm,
|
||||||
cancelLabel: labels.cancel,
|
cancelLabel: labels.cancel,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { SidebarStatusWorkspacePlacement } from "@/hooks/sidebar-workspaces-view-model";
|
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
|
||||||
|
|
||||||
export type StatusBucket = SidebarStatusWorkspacePlacement["statusBucket"];
|
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
|
||||||
|
|
||||||
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
|
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
|
||||||
"needs_input",
|
"needs_input",
|
||||||
@@ -21,14 +21,14 @@ export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
|
|||||||
export interface StatusGroup {
|
export interface StatusGroup {
|
||||||
bucket: StatusBucket;
|
bucket: StatusBucket;
|
||||||
label: string;
|
label: string;
|
||||||
rows: SidebarStatusWorkspacePlacement[];
|
rows: SidebarWorkspaceEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildStatusGroups(
|
export function buildStatusGroups(
|
||||||
workspaces: SidebarStatusWorkspacePlacement[],
|
workspaces: SidebarWorkspaceEntry[],
|
||||||
projectNamesByKey: Map<string, string>,
|
projectNamesByKey: Map<string, string>,
|
||||||
): StatusGroup[] {
|
): StatusGroup[] {
|
||||||
const bucketRows = new Map<StatusBucket, SidebarStatusWorkspacePlacement[]>();
|
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
|
||||||
|
|
||||||
for (const ws of workspaces) {
|
for (const ws of workspaces) {
|
||||||
const bucket: StatusBucket = ws.statusBucket;
|
const bucket: StatusBucket = ws.statusBucket;
|
||||||
@@ -54,8 +54,8 @@ export function buildStatusGroups(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function compareStatusRows(
|
function compareStatusRows(
|
||||||
a: SidebarStatusWorkspacePlacement,
|
a: SidebarWorkspaceEntry,
|
||||||
b: SidebarStatusWorkspacePlacement,
|
b: SidebarWorkspaceEntry,
|
||||||
projectNamesByKey: Map<string, string>,
|
projectNamesByKey: Map<string, string>,
|
||||||
): number {
|
): number {
|
||||||
const aTime = a.statusEnteredAt?.getTime() ?? null;
|
const aTime = a.statusEnteredAt?.getTime() ?? null;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
|||||||
import {
|
import {
|
||||||
appendMissingOrderKeys,
|
appendMissingOrderKeys,
|
||||||
applyStoredOrdering,
|
applyStoredOrdering,
|
||||||
buildSidebarStatusWorkspacePlacements,
|
buildSidebarWorkspaceEntries,
|
||||||
buildSidebarWorkspacePlacementModel,
|
buildSidebarWorkspacePlacementModel,
|
||||||
buildSidebarProjectsFromStructure,
|
buildSidebarProjectsFromStructure,
|
||||||
computeSidebarOrderUpdates,
|
computeSidebarOrderUpdates,
|
||||||
@@ -226,11 +226,12 @@ describe("shared sidebar workspace model", () => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const statusRows = buildSidebarStatusWorkspacePlacements({
|
const workspaceEntries = buildSidebarWorkspaceEntries({
|
||||||
placements: model.workspaces,
|
placements: model.workspaces,
|
||||||
sessions: [
|
sessions: [
|
||||||
{
|
{
|
||||||
serverId: "host-a",
|
serverId: "host-a",
|
||||||
|
workspaceAgentActivity: new Map(),
|
||||||
workspaces: new Map([
|
workspaces: new Map([
|
||||||
[
|
[
|
||||||
"main",
|
"main",
|
||||||
@@ -246,6 +247,7 @@ describe("shared sidebar workspace model", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: "host-b",
|
serverId: "host-b",
|
||||||
|
workspaceAgentActivity: new Map(),
|
||||||
workspaces: new Map([
|
workspaces: new Map([
|
||||||
[
|
[
|
||||||
"feature",
|
"feature",
|
||||||
@@ -288,14 +290,66 @@ describe("shared sidebar workspace model", () => {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(statusRows.map((entry) => [entry.workspaceKey, entry.statusBucket, entry.name])).toEqual(
|
expect(
|
||||||
[
|
Array.from(workspaceEntries.values()).map((entry) => [
|
||||||
["host-a:main", "done", "main"],
|
entry.workspaceKey,
|
||||||
["host-b:feature", "running", "feature/status-flow"],
|
entry.statusBucket,
|
||||||
],
|
entry.name,
|
||||||
);
|
]),
|
||||||
|
).toEqual([
|
||||||
|
["host-a:main", "done", "main"],
|
||||||
|
["host-b:feature", "running", "feature/status-flow"],
|
||||||
|
]);
|
||||||
expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
|
expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves unchanged row identities when another workspace updates", () => {
|
||||||
|
const model = buildSidebarWorkspacePlacementModel({
|
||||||
|
projects: [project({ projectKey: "project", workspaceKeys: ["srv:one", "srv:two"] })],
|
||||||
|
});
|
||||||
|
const one = workspace({
|
||||||
|
id: "one",
|
||||||
|
name: "one",
|
||||||
|
projectId: "project",
|
||||||
|
projectDisplayName: "project",
|
||||||
|
});
|
||||||
|
const two = workspace({
|
||||||
|
id: "two",
|
||||||
|
name: "two",
|
||||||
|
projectId: "project",
|
||||||
|
projectDisplayName: "project",
|
||||||
|
});
|
||||||
|
const previousEntries = buildSidebarWorkspaceEntries({
|
||||||
|
placements: model.workspaces,
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceAgentActivity: new Map(),
|
||||||
|
workspaces: new Map([
|
||||||
|
["one", one],
|
||||||
|
["two", two],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const nextEntries = buildSidebarWorkspaceEntries({
|
||||||
|
placements: model.workspaces,
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceAgentActivity: new Map(),
|
||||||
|
workspaces: new Map([
|
||||||
|
["one", one],
|
||||||
|
["two", { ...two, status: "running" }],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
previousEntries,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
|
||||||
|
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shouldShowSidebarHostLabels", () => {
|
describe("shouldShowSidebarHostLabels", () => {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type { PrHint } from "@/git/pr-hint";
|
|||||||
import { selectPrHintFromStatus } from "@/git/pr-hint";
|
import { selectPrHintFromStatus } from "@/git/pr-hint";
|
||||||
import { type HostProjectListItem } from "@/projects/host-project-model";
|
import { type HostProjectListItem } from "@/projects/host-project-model";
|
||||||
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
|
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import type {
|
import type {
|
||||||
WorkspaceStructureHostPlacement,
|
WorkspaceStructureHostPlacement,
|
||||||
WorkspaceStructureProject,
|
WorkspaceStructureProject,
|
||||||
} from "@/projects/workspace-structure";
|
} from "@/projects/workspace-structure";
|
||||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
|
||||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||||
|
|
||||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||||
@@ -63,10 +63,57 @@ export interface SidebarWorkspacePlacementModel {
|
|||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarStatusWorkspaceSession {
|
export interface SidebarWorkspaceSession {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaces: Map<string, WorkspaceDescriptor>;
|
workspaces: Map<string, WorkspaceDescriptor>;
|
||||||
agents?: Map<string, Agent>;
|
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarWorkspaceSessionSource {
|
||||||
|
workspaces: Map<string, WorkspaceDescriptor>;
|
||||||
|
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectSidebarWorkspaceSessions(
|
||||||
|
sessions: Record<string, SidebarWorkspaceSessionSource | undefined>,
|
||||||
|
serverIds: readonly string[],
|
||||||
|
): SidebarWorkspaceSession[] {
|
||||||
|
const selected: SidebarWorkspaceSession[] = [];
|
||||||
|
for (const serverId of serverIds) {
|
||||||
|
const session = sessions[serverId];
|
||||||
|
if (!session) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
selected.push({
|
||||||
|
serverId,
|
||||||
|
workspaces: session.workspaces,
|
||||||
|
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areSidebarWorkspaceSessionsEqual(
|
||||||
|
left: readonly SidebarWorkspaceSession[],
|
||||||
|
right: readonly SidebarWorkspaceSession[],
|
||||||
|
): boolean {
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
const leftSession = left[index];
|
||||||
|
const rightSession = right[index];
|
||||||
|
if (
|
||||||
|
!leftSession ||
|
||||||
|
!rightSession ||
|
||||||
|
leftSession.serverId !== rightSession.serverId ||
|
||||||
|
leftSession.workspaces !== rightSession.workspaces ||
|
||||||
|
leftSession.workspaceAgentActivity !== rightSession.workspaceAgentActivity
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EffectiveWorkspaceStatus {
|
interface EffectiveWorkspaceStatus {
|
||||||
@@ -74,8 +121,6 @@ interface EffectiveWorkspaceStatus {
|
|||||||
enteredAt: Date | null;
|
enteredAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
|
|
||||||
|
|
||||||
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
|
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
|
||||||
return (
|
return (
|
||||||
workspace.projectCustomName ??
|
workspace.projectCustomName ??
|
||||||
@@ -96,7 +141,7 @@ export function createSidebarWorkspaceEntry(input: {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
workspace: WorkspaceDescriptor;
|
workspace: WorkspaceDescriptor;
|
||||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||||
agents?: Map<string, Agent>;
|
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||||
}): SidebarWorkspaceEntry {
|
}): SidebarWorkspaceEntry {
|
||||||
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
|
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
|
||||||
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
||||||
@@ -129,7 +174,7 @@ function deriveEffectiveWorkspaceStatus(input: {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
workspace: WorkspaceDescriptor;
|
workspace: WorkspaceDescriptor;
|
||||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||||
agents?: Map<string, Agent>;
|
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||||
}): EffectiveWorkspaceStatus {
|
}): EffectiveWorkspaceStatus {
|
||||||
if (input.workspace.status !== "done") {
|
if (input.workspace.status !== "done") {
|
||||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||||
@@ -144,10 +189,7 @@ function deriveEffectiveWorkspaceStatus(input: {
|
|||||||
return { status: "running", enteredAt: pendingStartedAt };
|
return { status: "running", enteredAt: pendingStartedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootAgentActivity = getRootAgentWorkspaceActivity({
|
const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id);
|
||||||
workspace: input.workspace,
|
|
||||||
agents: input.agents,
|
|
||||||
});
|
|
||||||
if (rootAgentActivity && rootAgentActivity.status !== "done") {
|
if (rootAgentActivity && rootAgentActivity.status !== "done") {
|
||||||
return rootAgentActivity;
|
return rootAgentActivity;
|
||||||
}
|
}
|
||||||
@@ -173,28 +215,6 @@ function getPendingInitialAgentCreateStartedAt(input: {
|
|||||||
return latestStartedAt;
|
return latestStartedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRootAgentWorkspaceActivity(input: {
|
|
||||||
workspace: WorkspaceDescriptor;
|
|
||||||
agents?: Map<string, Agent>;
|
|
||||||
}): WorkspaceAgentActivity | null {
|
|
||||||
let latest: WorkspaceAgentActivity | null = null;
|
|
||||||
for (const agent of input.agents?.values() ?? []) {
|
|
||||||
if (agent.archivedAt || agent.parentAgentId) continue;
|
|
||||||
if (agent.workspaceId !== input.workspace.id) continue;
|
|
||||||
const status = deriveSidebarStateBucket({
|
|
||||||
status: agent.status,
|
|
||||||
pendingPermissionCount: agent.pendingPermissions.length,
|
|
||||||
requiresAttention: agent.requiresAttention,
|
|
||||||
attentionReason: agent.attentionReason,
|
|
||||||
});
|
|
||||||
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
|
|
||||||
if (!latest || enteredAt > (latest.enteredAt ?? new Date(0))) {
|
|
||||||
latest = { status, enteredAt };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return latest;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSidebarWorkspacePlacementModel(input: {
|
export function buildSidebarWorkspacePlacementModel(input: {
|
||||||
projects: readonly HostProjectListItem[];
|
projects: readonly HostProjectListItem[];
|
||||||
}): SidebarWorkspacePlacementModel {
|
}): SidebarWorkspacePlacementModel {
|
||||||
@@ -272,17 +292,18 @@ function resolveStructuralWorkspaceIdentity(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSidebarStatusWorkspacePlacements(input: {
|
export function buildSidebarWorkspaceEntries(input: {
|
||||||
placements: readonly SidebarWorkspacePlacement[];
|
placements: readonly SidebarWorkspacePlacement[];
|
||||||
sessions: SidebarStatusWorkspaceSession[];
|
sessions: SidebarWorkspaceSession[];
|
||||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||||
}): SidebarStatusWorkspacePlacement[] {
|
previousEntries?: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||||
|
}): Map<string, SidebarWorkspaceEntry> {
|
||||||
if (input.placements.length === 0 || input.sessions.length === 0) {
|
if (input.placements.length === 0 || input.sessions.length === 0) {
|
||||||
return [];
|
return new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionByServerId = new Map(input.sessions.map((session) => [session.serverId, session]));
|
const sessionByServerId = new Map(input.sessions.map((session) => [session.serverId, session]));
|
||||||
const rows: SidebarStatusWorkspacePlacement[] = [];
|
const entries = new Map<string, SidebarWorkspaceEntry>();
|
||||||
|
|
||||||
for (const placement of input.placements) {
|
for (const placement of input.placements) {
|
||||||
const session = sessionByServerId.get(placement.serverId);
|
const session = sessionByServerId.get(placement.serverId);
|
||||||
@@ -294,24 +315,46 @@ export function buildSidebarStatusWorkspacePlacements(input: {
|
|||||||
const workspace = workspaceKey ? session.workspaces.get(workspaceKey) : null;
|
const workspace = workspaceKey ? session.workspaces.get(workspaceKey) : null;
|
||||||
if (!workspace) continue;
|
if (!workspace) continue;
|
||||||
|
|
||||||
const effectiveStatus = deriveEffectiveWorkspaceStatus({
|
const entry = createSidebarWorkspaceEntry({
|
||||||
serverId: placement.serverId,
|
serverId: placement.serverId,
|
||||||
workspace,
|
workspace,
|
||||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||||
agents: session.agents,
|
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||||
});
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
...placement,
|
|
||||||
name: workspace.name,
|
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
|
||||||
workspaceKind: workspace.workspaceKind,
|
|
||||||
statusBucket: effectiveStatus.status,
|
|
||||||
statusEnteredAt: effectiveStatus.enteredAt,
|
|
||||||
});
|
});
|
||||||
|
const previousEntry = input.previousEntries?.get(placement.workspaceKey);
|
||||||
|
entries.set(
|
||||||
|
placement.workspaceKey,
|
||||||
|
previousEntry && areSidebarWorkspaceEntriesEqual(previousEntry, entry)
|
||||||
|
? previousEntry
|
||||||
|
: entry,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function areSidebarWorkspaceEntriesEqual(
|
||||||
|
left: SidebarWorkspaceEntry,
|
||||||
|
right: SidebarWorkspaceEntry,
|
||||||
|
): boolean {
|
||||||
|
const keys = Object.keys(left) as Array<keyof SidebarWorkspaceEntry>;
|
||||||
|
if (keys.length !== Object.keys(right).length) return false;
|
||||||
|
return keys.every((key) => {
|
||||||
|
if (key !== "prHint") return Object.is(left[key], right[key]);
|
||||||
|
const leftHint = left.prHint;
|
||||||
|
const rightHint = right.prHint;
|
||||||
|
return (
|
||||||
|
leftHint === rightHint ||
|
||||||
|
(leftHint !== null &&
|
||||||
|
rightHint !== null &&
|
||||||
|
leftHint.url === rightHint.url &&
|
||||||
|
leftHint.number === rightHint.number &&
|
||||||
|
leftHint.state === rightHint.state &&
|
||||||
|
leftHint.checks === rightHint.checks &&
|
||||||
|
leftHint.checksStatus === rightHint.checksStatus &&
|
||||||
|
leftHint.reviewDecision === rightHint.reviewDecision)
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSidebarProjectsFromStructure(input: {
|
export function buildSidebarProjectsFromStructure(input: {
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { Gesture } from "react-native-gesture-handler";
|
|
||||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
import { canOpenRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
|
||||||
|
|
||||||
interface UseExplorerOpenGestureParams {
|
|
||||||
enabled: boolean;
|
|
||||||
onOpen: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
|
|
||||||
|
|
||||||
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
|
|
||||||
const {
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
setOverlayPeek,
|
|
||||||
isGesturing,
|
|
||||||
gestureAnimatingRef,
|
|
||||||
openGestureRef,
|
|
||||||
} = useExplorerSidebarAnimation();
|
|
||||||
const {
|
|
||||||
mobilePanelState,
|
|
||||||
gestureAnimatingRef: mobilePanelGestureAnimatingRef,
|
|
||||||
openGestureRef: leftOpenGestureRef,
|
|
||||||
} = useSidebarAnimation();
|
|
||||||
const touchStartX = useSharedValue(0);
|
|
||||||
const touchStartY = useSharedValue(0);
|
|
||||||
|
|
||||||
const handleGestureOpen = useCallback(() => {
|
|
||||||
gestureAnimatingRef.current = true;
|
|
||||||
mobilePanelGestureAnimatingRef.current = true;
|
|
||||||
onOpen();
|
|
||||||
}, [onOpen, gestureAnimatingRef, mobilePanelGestureAnimatingRef]);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() =>
|
|
||||||
Gesture.Pan()
|
|
||||||
.withRef(openGestureRef)
|
|
||||||
.simultaneousWithExternalGesture(leftOpenGestureRef)
|
|
||||||
.enabled(enabled)
|
|
||||||
.manualActivation(true)
|
|
||||||
.onTouchesDown((event) => {
|
|
||||||
const touch = event.changedTouches[0];
|
|
||||||
if (!touch) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
touchStartX.value = touch.absoluteX;
|
|
||||||
touchStartY.value = touch.absoluteY;
|
|
||||||
})
|
|
||||||
.onTouchesMove((event, stateManager) => {
|
|
||||||
const touch = event.changedTouches[0];
|
|
||||||
if (!touch || event.numberOfTouches !== 1) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaX = touch.absoluteX - touchStartX.value;
|
|
||||||
const deltaY = touch.absoluteY - touchStartY.value;
|
|
||||||
const absDeltaX = Math.abs(deltaX);
|
|
||||||
const absDeltaY = Math.abs(deltaY);
|
|
||||||
|
|
||||||
if (!canOpenRightSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser back-swipe owns most of the viewport; keep this gesture on the right edge.
|
|
||||||
if (isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fail quickly on rightward or clearly vertical intent.
|
|
||||||
if (deltaX >= 10) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
|
||||||
stateManager.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activate only on intentional leftward movement.
|
|
||||||
if (deltaX <= -15 && absDeltaX > absDeltaY) {
|
|
||||||
stateManager.activate();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onStart(() => {
|
|
||||||
isGesturing.value = true;
|
|
||||||
// The overlay is display:none while closed; reveal it for the drag.
|
|
||||||
runOnJS(setOverlayPeek)(true);
|
|
||||||
})
|
|
||||||
.onUpdate((event) => {
|
|
||||||
// Right sidebar: start from closed position (+windowWidth) and move towards 0.
|
|
||||||
const newTranslateX = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(windowWidth, windowWidth + event.translationX),
|
|
||||||
);
|
|
||||||
translateX.value = newTranslateX;
|
|
||||||
backdropOpacity.value = interpolate(
|
|
||||||
newTranslateX,
|
|
||||||
[windowWidth, 0],
|
|
||||||
[0, 1],
|
|
||||||
Extrapolation.CLAMP,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.onEnd((event) => {
|
|
||||||
isGesturing.value = false;
|
|
||||||
const shouldOpenByPosition = translateX.value < (windowWidth * 2) / 3;
|
|
||||||
const shouldOpenByVelocity = event.velocityX < -500;
|
|
||||||
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
|
|
||||||
if (shouldOpen) {
|
|
||||||
animateToOpen();
|
|
||||||
runOnJS(handleGestureOpen)();
|
|
||||||
} else {
|
|
||||||
animateToClose();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onFinalize(() => {
|
|
||||||
isGesturing.value = false;
|
|
||||||
runOnJS(setOverlayPeek)(false);
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
enabled,
|
|
||||||
windowWidth,
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
mobilePanelState,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
setOverlayPeek,
|
|
||||||
isGesturing,
|
|
||||||
openGestureRef,
|
|
||||||
leftOpenGestureRef,
|
|
||||||
handleGestureOpen,
|
|
||||||
touchStartX,
|
|
||||||
touchStartY,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
|
||||||
|
import {
|
||||||
|
areSidebarWorkspaceSessionsEqual,
|
||||||
|
selectSidebarWorkspaceSessions,
|
||||||
|
type SidebarWorkspaceSession,
|
||||||
|
} from "./sidebar-workspaces-view-model";
|
||||||
|
|
||||||
|
function workspaceMap(): Map<string, WorkspaceDescriptor> {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityMap(): Map<string, WorkspaceAgentActivity> {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sidebarSession(input?: Partial<Omit<SidebarWorkspaceSession, "serverId">>) {
|
||||||
|
return {
|
||||||
|
workspaces: input?.workspaces ?? workspaceMap(),
|
||||||
|
workspaceAgentActivity: input?.workspaceAgentActivity ?? activityMap(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sidebar workspace session selection", () => {
|
||||||
|
it("selects only sessions needed by sidebar placements", () => {
|
||||||
|
const hostA = sidebarSession();
|
||||||
|
const hostB = sidebarSession();
|
||||||
|
const unusedHost = sidebarSession();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
selectSidebarWorkspaceSessions(
|
||||||
|
{
|
||||||
|
"host-a": hostA,
|
||||||
|
"host-b": hostB,
|
||||||
|
unused: unusedHost,
|
||||||
|
},
|
||||||
|
["host-b", "missing", "host-a"],
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
serverId: "host-b",
|
||||||
|
workspaces: hostB.workspaces,
|
||||||
|
workspaceAgentActivity: hostB.workspaceAgentActivity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: "host-a",
|
||||||
|
workspaces: hostA.workspaces,
|
||||||
|
workspaceAgentActivity: hostA.workspaceAgentActivity,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores high-frequency session changes outside the sidebar indexes", () => {
|
||||||
|
const workspaces = workspaceMap();
|
||||||
|
const workspaceAgentActivity = activityMap();
|
||||||
|
|
||||||
|
const previous = selectSidebarWorkspaceSessions(
|
||||||
|
{ "host-a": sidebarSession({ workspaces, workspaceAgentActivity }) },
|
||||||
|
["host-a"],
|
||||||
|
);
|
||||||
|
const next = selectSidebarWorkspaceSessions(
|
||||||
|
{ "host-a": sidebarSession({ workspaces, workspaceAgentActivity }) },
|
||||||
|
["host-a"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(previous).not.toBe(next);
|
||||||
|
expect(areSidebarWorkspaceSessionsEqual(previous, next)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects changes to a selected workspace or activity index", () => {
|
||||||
|
const workspaceAgentActivity = activityMap();
|
||||||
|
const previous = selectSidebarWorkspaceSessions(
|
||||||
|
{ "host-a": sidebarSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
|
||||||
|
["host-a"],
|
||||||
|
);
|
||||||
|
const next = selectSidebarWorkspaceSessions(
|
||||||
|
{ "host-a": sidebarSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
|
||||||
|
["host-a"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(areSidebarWorkspaceSessionsEqual(previous, next)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import {
|
||||||
|
areSidebarWorkspaceSessionsEqual,
|
||||||
|
buildSidebarWorkspaceEntries,
|
||||||
|
selectSidebarWorkspaceSessions,
|
||||||
|
type SidebarWorkspaceEntry,
|
||||||
|
type SidebarWorkspacePlacement,
|
||||||
|
type SidebarWorkspaceSession,
|
||||||
|
} from "./sidebar-workspaces-view-model";
|
||||||
|
|
||||||
|
const EMPTY_ENTRIES = new Map<string, SidebarWorkspaceEntry>();
|
||||||
|
const EMPTY_SESSIONS: SidebarWorkspaceSession[] = [];
|
||||||
|
const EMPTY_PENDING_CREATE_ATTEMPTS: Record<string, never> = {};
|
||||||
|
|
||||||
|
export function useSidebarWorkspaceEntries(
|
||||||
|
placements: readonly SidebarWorkspacePlacement[],
|
||||||
|
enabled = true,
|
||||||
|
): ReadonlyMap<string, SidebarWorkspaceEntry> {
|
||||||
|
const serverIds = useMemo(
|
||||||
|
() => Array.from(new Set(placements.map((placement) => placement.serverId))),
|
||||||
|
[placements],
|
||||||
|
);
|
||||||
|
const sessions = useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) =>
|
||||||
|
enabled ? selectSidebarWorkspaceSessions(state.sessions, serverIds) : EMPTY_SESSIONS,
|
||||||
|
areSidebarWorkspaceSessionsEqual,
|
||||||
|
);
|
||||||
|
const pendingCreateAttempts = useCreateFlowStore((state) =>
|
||||||
|
enabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
|
||||||
|
);
|
||||||
|
const previousEntriesRef = useRef<ReadonlyMap<string, SidebarWorkspaceEntry>>(EMPTY_ENTRIES);
|
||||||
|
|
||||||
|
// Collection ownership is intentional: retained sidebars have one cheap
|
||||||
|
// subscription to structurally shared indexes, never one session-store
|
||||||
|
// subscription per mounted row.
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!enabled || placements.length === 0 || sessions.length === 0) {
|
||||||
|
previousEntriesRef.current = EMPTY_ENTRIES;
|
||||||
|
return EMPTY_ENTRIES;
|
||||||
|
}
|
||||||
|
const entries = buildSidebarWorkspaceEntries({
|
||||||
|
placements,
|
||||||
|
sessions,
|
||||||
|
pendingCreateAttempts,
|
||||||
|
previousEntries: previousEntriesRef.current,
|
||||||
|
});
|
||||||
|
previousEntriesRef.current = entries;
|
||||||
|
return entries;
|
||||||
|
}, [enabled, pendingCreateAttempts, placements, sessions]);
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import equal from "fast-deep-equal";
|
|
||||||
import { shallow } from "zustand/shallow";
|
import { shallow } from "zustand/shallow";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
|
||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
|
|
||||||
import { useHostProjects } from "@/projects/host-projects";
|
import { useHostProjects } from "@/projects/host-projects";
|
||||||
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
|
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
|
||||||
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
||||||
@@ -14,7 +11,6 @@ import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-works
|
|||||||
import {
|
import {
|
||||||
buildSidebarWorkspacePlacementModel,
|
buildSidebarWorkspacePlacementModel,
|
||||||
computeSidebarOrderUpdates,
|
computeSidebarOrderUpdates,
|
||||||
createSidebarWorkspaceEntry,
|
|
||||||
deriveSidebarLoadingState,
|
deriveSidebarLoadingState,
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
@@ -26,10 +22,9 @@ export {
|
|||||||
applyStoredOrdering,
|
applyStoredOrdering,
|
||||||
buildSidebarProjectsFromHostProjects,
|
buildSidebarProjectsFromHostProjects,
|
||||||
buildSidebarProjectsFromStructure,
|
buildSidebarProjectsFromStructure,
|
||||||
buildSidebarStatusWorkspacePlacements,
|
createSidebarWorkspaceEntry,
|
||||||
buildSidebarWorkspacePlacementModel,
|
buildSidebarWorkspacePlacementModel,
|
||||||
computeSidebarOrderUpdates,
|
computeSidebarOrderUpdates,
|
||||||
createSidebarWorkspaceEntry,
|
|
||||||
deriveSidebarLoadingState,
|
deriveSidebarLoadingState,
|
||||||
shouldShowSidebarHostLabels,
|
shouldShowSidebarHostLabels,
|
||||||
type SidebarLoadingState,
|
type SidebarLoadingState,
|
||||||
@@ -42,37 +37,6 @@ export {
|
|||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
} from "./sidebar-workspaces-view-model";
|
} from "./sidebar-workspaces-view-model";
|
||||||
|
|
||||||
export function useSidebarWorkspaceEntry(
|
|
||||||
serverId: string | null,
|
|
||||||
workspaceId: string | null,
|
|
||||||
): SidebarWorkspaceEntry | null {
|
|
||||||
// Deep-compare so that adding/removing unrelated pending creates doesn't re-render this row.
|
|
||||||
const pendingCreateAttempts = useStoreWithEqualityFn(
|
|
||||||
useCreateFlowStore,
|
|
||||||
(state) => state.pendingByDraftId,
|
|
||||||
workspaceEqualityFns.deep,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Single subscription: reads workspace + agents together, computes the full entry, and
|
|
||||||
// deep-compares the output. Agents-Map identity churn (setAgents replaces the Map on every
|
|
||||||
// status transition) never causes a React re-render unless the derived entry actually changes.
|
|
||||||
return useStoreWithEqualityFn(
|
|
||||||
useSessionStore,
|
|
||||||
(state) => {
|
|
||||||
const workspace = selectWorkspace(state, serverId, workspaceId);
|
|
||||||
if (!workspace) return null;
|
|
||||||
const agents = serverId ? state.sessions[serverId]?.agents : undefined;
|
|
||||||
return createSidebarWorkspaceEntry({
|
|
||||||
serverId: serverId ?? "",
|
|
||||||
workspace,
|
|
||||||
pendingCreateAttempts,
|
|
||||||
agents,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
equal,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_ORDER: string[] = [];
|
const EMPTY_ORDER: string[] = [];
|
||||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||||
const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = [];
|
const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = [];
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
areStatusModeSessionsEqual,
|
|
||||||
selectStatusModeSessions,
|
|
||||||
type StatusModeSession,
|
|
||||||
} from "./use-status-mode-workspaces";
|
|
||||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
|
||||||
|
|
||||||
function workspaceMap(): Map<string, WorkspaceDescriptor> {
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
function agentMap(): Map<string, Agent> {
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusSession(input?: Partial<Omit<StatusModeSession, "serverId">>) {
|
|
||||||
return {
|
|
||||||
workspaces: input?.workspaces ?? workspaceMap(),
|
|
||||||
agents: input?.agents ?? agentMap(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("status mode session selection", () => {
|
|
||||||
it("selects only sessions needed by visible placements", () => {
|
|
||||||
const hostA = statusSession();
|
|
||||||
const hostB = statusSession();
|
|
||||||
const unusedHost = statusSession();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
selectStatusModeSessions(
|
|
||||||
{
|
|
||||||
"host-a": hostA,
|
|
||||||
"host-b": hostB,
|
|
||||||
unused: unusedHost,
|
|
||||||
},
|
|
||||||
["host-b", "missing", "host-a"],
|
|
||||||
),
|
|
||||||
).toEqual([
|
|
||||||
{ serverId: "host-b", workspaces: hostB.workspaces, agents: hostB.agents },
|
|
||||||
{ serverId: "host-a", workspaces: hostA.workspaces, agents: hostA.agents },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps selector output equal when only wrapper objects change", () => {
|
|
||||||
const workspaces = workspaceMap();
|
|
||||||
const agents = agentMap();
|
|
||||||
|
|
||||||
const previous = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
|
|
||||||
"host-a",
|
|
||||||
]);
|
|
||||||
const next = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
|
|
||||||
"host-a",
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(previous).not.toBe(next);
|
|
||||||
expect(areStatusModeSessionsEqual(previous, next)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects workspace or agent map changes for selected hosts", () => {
|
|
||||||
const agents = agentMap();
|
|
||||||
const previous = selectStatusModeSessions(
|
|
||||||
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
|
|
||||||
["host-a"],
|
|
||||||
);
|
|
||||||
const next = selectStatusModeSessions(
|
|
||||||
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
|
|
||||||
["host-a"],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(areStatusModeSessionsEqual(previous, next)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
|
||||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
|
||||||
import {
|
|
||||||
buildSidebarStatusWorkspacePlacements,
|
|
||||||
type SidebarStatusWorkspacePlacement,
|
|
||||||
type SidebarWorkspacePlacement,
|
|
||||||
} from "./use-sidebar-workspaces-list";
|
|
||||||
|
|
||||||
const EMPTY_WORKSPACES: SidebarStatusWorkspacePlacement[] = [];
|
|
||||||
const EMPTY_STATUS_SESSIONS: StatusModeSession[] = [];
|
|
||||||
const EMPTY_PENDING_CREATE_ATTEMPTS: ReturnType<
|
|
||||||
typeof useCreateFlowStore.getState
|
|
||||||
>["pendingByDraftId"] = {};
|
|
||||||
|
|
||||||
interface StatusModeSessionSource {
|
|
||||||
workspaces: SessionState["workspaces"];
|
|
||||||
agents: SessionState["agents"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatusModeSession {
|
|
||||||
serverId: string;
|
|
||||||
workspaces: SessionState["workspaces"];
|
|
||||||
agents: SessionState["agents"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectStatusModeSessions(
|
|
||||||
sessions: Record<string, StatusModeSessionSource | undefined>,
|
|
||||||
serverIds: readonly string[],
|
|
||||||
): StatusModeSession[] {
|
|
||||||
const statusSessions: StatusModeSession[] = [];
|
|
||||||
for (const serverId of serverIds) {
|
|
||||||
const session = sessions[serverId];
|
|
||||||
if (!session) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
statusSessions.push({
|
|
||||||
serverId,
|
|
||||||
workspaces: session.workspaces,
|
|
||||||
agents: session.agents,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return statusSessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function areStatusModeSessionsEqual(
|
|
||||||
left: readonly StatusModeSession[],
|
|
||||||
right: readonly StatusModeSession[],
|
|
||||||
): boolean {
|
|
||||||
if (left.length !== right.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (let index = 0; index < left.length; index += 1) {
|
|
||||||
const leftSession = left[index];
|
|
||||||
const rightSession = right[index];
|
|
||||||
if (
|
|
||||||
!leftSession ||
|
|
||||||
!rightSession ||
|
|
||||||
leftSession.serverId !== rightSession.serverId ||
|
|
||||||
leftSession.workspaces !== rightSession.workspaces ||
|
|
||||||
leftSession.agents !== rightSession.agents
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useStatusModeWorkspacePlacements(input: {
|
|
||||||
placements: SidebarWorkspacePlacement[];
|
|
||||||
enabled?: boolean;
|
|
||||||
}): SidebarStatusWorkspacePlacement[] {
|
|
||||||
const isEnabled = input.enabled !== false && input.placements.length > 0;
|
|
||||||
const serverIds = useMemo(
|
|
||||||
() => Array.from(new Set(input.placements.map((placement) => placement.serverId))),
|
|
||||||
[input.placements],
|
|
||||||
);
|
|
||||||
const statusSessions = useStoreWithEqualityFn(
|
|
||||||
useSessionStore,
|
|
||||||
(state) =>
|
|
||||||
isEnabled ? selectStatusModeSessions(state.sessions, serverIds) : EMPTY_STATUS_SESSIONS,
|
|
||||||
areStatusModeSessionsEqual,
|
|
||||||
);
|
|
||||||
const pendingCreateAttempts = useCreateFlowStore((state) =>
|
|
||||||
isEnabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
if (!isEnabled) {
|
|
||||||
return EMPTY_WORKSPACES;
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildSidebarStatusWorkspacePlacements({
|
|
||||||
placements: input.placements,
|
|
||||||
sessions: statusSessions,
|
|
||||||
pendingCreateAttempts,
|
|
||||||
});
|
|
||||||
}, [input.placements, isEnabled, pendingCreateAttempts, statusSessions]);
|
|
||||||
}
|
|
||||||
@@ -386,7 +386,7 @@ describe("translation resources", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes picker, file pane, and tool detail keys for the Batch 4L migration", () => {
|
it("includes picker, file pane, and tool detail keys for the Batch 4L migration", () => {
|
||||||
expect(en.projectPicker.placeholder).toBe("Type a directory path...");
|
expect(en.projectPicker.placeholder).toBe("Type to search...");
|
||||||
expect(en.projectPicker.opening).toBe("Opening project...");
|
expect(en.projectPicker.opening).toBe("Opening project...");
|
||||||
expect(en.projectPicker.searching).toBe("Searching...");
|
expect(en.projectPicker.searching).toBe("Searching...");
|
||||||
expect(en.projectPicker.empty).toBe("Start typing a path");
|
expect(en.projectPicker.empty).toBe("Start typing a path");
|
||||||
|
|||||||
@@ -619,7 +619,7 @@ export const ar: TranslationResources = {
|
|||||||
success: "تم التحديث",
|
success: "تم التحديث",
|
||||||
},
|
},
|
||||||
archive: {
|
archive: {
|
||||||
label: "أرشفة شجرة العمل",
|
label: "أرشفة مساحة العمل",
|
||||||
pending: "أرشفة...",
|
pending: "أرشفة...",
|
||||||
success: "مؤرشف",
|
success: "مؤرشف",
|
||||||
},
|
},
|
||||||
@@ -661,8 +661,6 @@ export const ar: TranslationResources = {
|
|||||||
updateNoBase: "التحديث غير متاح لأننا لم نتمكن من تحديد الفرع الأساسي",
|
updateNoBase: "التحديث غير متاح لأننا لم نتمكن من تحديد الفرع الأساسي",
|
||||||
updateDirty: "التحديث غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
|
updateDirty: "التحديث غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
|
||||||
updateCurrent: "التحديث غير متاح لأن هذا الفرع محدث بالفعل باستخدام{{baseRef}}",
|
updateCurrent: "التحديث غير متاح لأن هذا الفرع محدث بالفعل باستخدام{{baseRef}}",
|
||||||
archiveNotWorktree:
|
|
||||||
"الأرشيف غير متاح هنا لأنه لم يتم إنشاء مساحة العمل هذه كشجرة عمل Paseo",
|
|
||||||
mergePrNoGithub: "دمج PR غير متاح الآن لأن GitHub غير متصل",
|
mergePrNoGithub: "دمج PR غير متاح الآن لأن GitHub غير متصل",
|
||||||
mergePrMissing: "دمج PR غير متاح لأنه لا يوجد طلب سحب حتى الآن",
|
mergePrMissing: "دمج PR غير متاح لأنه لا يوجد طلب سحب حتى الآن",
|
||||||
mergePrDraft: "دمج PR غير متاح لأن طلب السحب لا يزال مسودة",
|
mergePrDraft: "دمج PR غير متاح لأن طلب السحب لا يزال مسودة",
|
||||||
@@ -685,11 +683,9 @@ export const ar: TranslationResources = {
|
|||||||
baseRefUnavailable: "المرجع الأساسي غير متاح",
|
baseRefUnavailable: "المرجع الأساسي غير متاح",
|
||||||
failedMerge: "فشل الدمج",
|
failedMerge: "فشل الدمج",
|
||||||
failedMergeFromBase: "فشل الدمج من القاعدة",
|
failedMergeFromBase: "فشل الدمج من القاعدة",
|
||||||
worktreePathUnavailable: "مسار شجرة العمل غير متوفر",
|
|
||||||
failedArchive: "فشل في أرشفة شجرة العمل",
|
|
||||||
},
|
},
|
||||||
archiveWarning: {
|
archiveWarning: {
|
||||||
title: 'الأرشيف "{{worktreeName}}"؟',
|
title: 'الأرشيف "{{workspaceName}}"؟',
|
||||||
confirm: "أرشيف",
|
confirm: "أرشيف",
|
||||||
cancel: "يلغي",
|
cancel: "يلغي",
|
||||||
uncommittedChanges: "تغييرات غير ملتزم بها",
|
uncommittedChanges: "تغييرات غير ملتزم بها",
|
||||||
@@ -839,7 +835,7 @@ export const ar: TranslationResources = {
|
|||||||
copyBranchName: "انسخ اسم الفرع",
|
copyBranchName: "انسخ اسم الفرع",
|
||||||
rename: "إعادة تسمية مساحة العمل",
|
rename: "إعادة تسمية مساحة العمل",
|
||||||
archive: "أرشيف",
|
archive: "أرشيف",
|
||||||
archiveWorktree: "أرشفة شجرة العمل",
|
archiveWorkspace: "أرشفة مساحة العمل",
|
||||||
hideFromSidebar: "إخفاء من الشريط الجانبي",
|
hideFromSidebar: "إخفاء من الشريط الجانبي",
|
||||||
archiving: "أرشفة...",
|
archiving: "أرشفة...",
|
||||||
hiding: "إخفاء...",
|
hiding: "إخفاء...",
|
||||||
@@ -862,7 +858,7 @@ export const ar: TranslationResources = {
|
|||||||
branchNameCopied: "تم نسخ اسم الفرع",
|
branchNameCopied: "تم نسخ اسم الفرع",
|
||||||
hostDisconnected: "Host غير متصل",
|
hostDisconnected: "Host غير متصل",
|
||||||
hideFailed: "فشل في إخفاء مساحة العمل",
|
hideFailed: "فشل في إخفاء مساحة العمل",
|
||||||
archiveFailed: "فشل في أرشفة شجرة العمل",
|
archiveFailed: "فشل في أرشفة مساحة العمل",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1093,7 +1089,8 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "اكتب مسار الدليل...",
|
placeholder: "اكتب للبحث...",
|
||||||
|
browse: "استعراض…",
|
||||||
opening: "افتتاح المشروع...",
|
opening: "افتتاح المشروع...",
|
||||||
searching: "جارٍ البحث...",
|
searching: "جارٍ البحث...",
|
||||||
empty: "ابدأ بكتابة المسار",
|
empty: "ابدأ بكتابة المسار",
|
||||||
@@ -1604,7 +1601,7 @@ export const ar: TranslationResources = {
|
|||||||
openProject: "مشروع مفتوح",
|
openProject: "مشروع مفتوح",
|
||||||
newWorkspace: "مساحة عمل جديدة",
|
newWorkspace: "مساحة عمل جديدة",
|
||||||
newWorktree: "شجرة عمل جديدة",
|
newWorktree: "شجرة عمل جديدة",
|
||||||
archiveWorktree: "أرشفة شجرة العمل",
|
archiveWorkspace: "أرشفة مساحة العمل",
|
||||||
newTab: "علامة تبويب جديدة",
|
newTab: "علامة تبويب جديدة",
|
||||||
closeCurrentTab: "إغلاق علامة التبويب الحالية",
|
closeCurrentTab: "إغلاق علامة التبويب الحالية",
|
||||||
jumpToWorkspace: "انتقل إلى مساحة العمل",
|
jumpToWorkspace: "انتقل إلى مساحة العمل",
|
||||||
|
|||||||
@@ -618,7 +618,7 @@ export const en = {
|
|||||||
success: "Updated",
|
success: "Updated",
|
||||||
},
|
},
|
||||||
archive: {
|
archive: {
|
||||||
label: "Archive worktree",
|
label: "Archive workspace",
|
||||||
pending: "Archiving...",
|
pending: "Archiving...",
|
||||||
success: "Archived",
|
success: "Archived",
|
||||||
},
|
},
|
||||||
@@ -667,8 +667,6 @@ export const en = {
|
|||||||
"Update isn't available while you have local changes so commit or stash them first",
|
"Update isn't available while you have local changes so commit or stash them first",
|
||||||
updateCurrent:
|
updateCurrent:
|
||||||
"Update isn't available because this branch is already up to date with {{baseRef}}",
|
"Update isn't available because this branch is already up to date with {{baseRef}}",
|
||||||
archiveNotWorktree:
|
|
||||||
"Archive isn't available here because this workspace was not created as a Paseo worktree",
|
|
||||||
mergePrNoGithub: "Merge PR isn't available right now because GitHub isn't connected",
|
mergePrNoGithub: "Merge PR isn't available right now because GitHub isn't connected",
|
||||||
mergePrMissing: "Merge PR isn't available because there isn't a pull request yet",
|
mergePrMissing: "Merge PR isn't available because there isn't a pull request yet",
|
||||||
mergePrDraft: "Merge PR isn't available because the pull request is still a draft",
|
mergePrDraft: "Merge PR isn't available because the pull request is still a draft",
|
||||||
@@ -692,11 +690,9 @@ export const en = {
|
|||||||
baseRefUnavailable: "Base ref unavailable",
|
baseRefUnavailable: "Base ref unavailable",
|
||||||
failedMerge: "Failed to merge",
|
failedMerge: "Failed to merge",
|
||||||
failedMergeFromBase: "Failed to merge from base",
|
failedMergeFromBase: "Failed to merge from base",
|
||||||
worktreePathUnavailable: "Worktree path unavailable",
|
|
||||||
failedArchive: "Failed to archive worktree",
|
|
||||||
},
|
},
|
||||||
archiveWarning: {
|
archiveWarning: {
|
||||||
title: 'Archive "{{worktreeName}}"?',
|
title: 'Archive "{{workspaceName}}"?',
|
||||||
confirm: "Archive",
|
confirm: "Archive",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
uncommittedChanges: "Uncommitted changes",
|
uncommittedChanges: "Uncommitted changes",
|
||||||
@@ -846,7 +842,7 @@ export const en = {
|
|||||||
copyBranchName: "Copy branch name",
|
copyBranchName: "Copy branch name",
|
||||||
rename: "Rename workspace",
|
rename: "Rename workspace",
|
||||||
archive: "Archive",
|
archive: "Archive",
|
||||||
archiveWorktree: "Archive worktree",
|
archiveWorkspace: "Archive workspace",
|
||||||
hideFromSidebar: "Hide from sidebar",
|
hideFromSidebar: "Hide from sidebar",
|
||||||
archiving: "Archiving...",
|
archiving: "Archiving...",
|
||||||
hiding: "Hiding...",
|
hiding: "Hiding...",
|
||||||
@@ -869,7 +865,7 @@ export const en = {
|
|||||||
branchNameCopied: "Branch name copied",
|
branchNameCopied: "Branch name copied",
|
||||||
hostDisconnected: "Host is not connected",
|
hostDisconnected: "Host is not connected",
|
||||||
hideFailed: "Failed to hide workspace",
|
hideFailed: "Failed to hide workspace",
|
||||||
archiveFailed: "Failed to archive worktree",
|
archiveFailed: "Failed to archive workspace",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1100,7 +1096,8 @@ export const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "Type a directory path...",
|
placeholder: "Type to search...",
|
||||||
|
browse: "Browse…",
|
||||||
opening: "Opening project...",
|
opening: "Opening project...",
|
||||||
searching: "Searching...",
|
searching: "Searching...",
|
||||||
empty: "Start typing a path",
|
empty: "Start typing a path",
|
||||||
@@ -1611,7 +1608,7 @@ export const en = {
|
|||||||
openProject: "Open project",
|
openProject: "Open project",
|
||||||
newWorkspace: "New workspace",
|
newWorkspace: "New workspace",
|
||||||
newWorktree: "New worktree",
|
newWorktree: "New worktree",
|
||||||
archiveWorktree: "Archive worktree",
|
archiveWorkspace: "Archive workspace",
|
||||||
newTab: "New tab",
|
newTab: "New tab",
|
||||||
closeCurrentTab: "Close current tab",
|
closeCurrentTab: "Close current tab",
|
||||||
jumpToWorkspace: "Jump to workspace",
|
jumpToWorkspace: "Jump to workspace",
|
||||||
|
|||||||
@@ -625,7 +625,7 @@ export const es: TranslationResources = {
|
|||||||
success: "Actualizado",
|
success: "Actualizado",
|
||||||
},
|
},
|
||||||
archive: {
|
archive: {
|
||||||
label: "Árbol de trabajo de archivo",
|
label: "Archivar espacio de trabajo",
|
||||||
pending: "Archivando...",
|
pending: "Archivando...",
|
||||||
success: "Archivado",
|
success: "Archivado",
|
||||||
},
|
},
|
||||||
@@ -679,8 +679,6 @@ export const es: TranslationResources = {
|
|||||||
"La actualización no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero",
|
"La actualización no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero",
|
||||||
updateCurrent:
|
updateCurrent:
|
||||||
"La actualización no está disponible porque esta rama ya está actualizada con{{baseRef}}",
|
"La actualización no está disponible porque esta rama ya está actualizada con{{baseRef}}",
|
||||||
archiveNotWorktree:
|
|
||||||
"El archivo no está disponible aquí porque este espacio de trabajo no se creó como un árbol de trabajoPaseo",
|
|
||||||
mergePrNoGithub:
|
mergePrNoGithub:
|
||||||
"FusionarPRno está disponible en este momento porqueGitHubno está conectado",
|
"FusionarPRno está disponible en este momento porqueGitHubno está conectado",
|
||||||
mergePrMissing:
|
mergePrMissing:
|
||||||
@@ -712,11 +710,9 @@ export const es: TranslationResources = {
|
|||||||
baseRefUnavailable: "Referencia base no disponible",
|
baseRefUnavailable: "Referencia base no disponible",
|
||||||
failedMerge: "No se pudo fusionar",
|
failedMerge: "No se pudo fusionar",
|
||||||
failedMergeFromBase: "No se pudo fusionar desde la base",
|
failedMergeFromBase: "No se pudo fusionar desde la base",
|
||||||
worktreePathUnavailable: "Ruta del árbol de trabajo no disponible",
|
|
||||||
failedArchive: "No se pudo archivar el árbol de trabajo",
|
|
||||||
},
|
},
|
||||||
archiveWarning: {
|
archiveWarning: {
|
||||||
title: '¿Archivo "{{worktreeName}}"?',
|
title: '¿Archivo "{{workspaceName}}"?',
|
||||||
confirm: "Archivo",
|
confirm: "Archivo",
|
||||||
cancel: "Cancelar",
|
cancel: "Cancelar",
|
||||||
uncommittedChanges: "Cambios no confirmados",
|
uncommittedChanges: "Cambios no confirmados",
|
||||||
@@ -866,7 +862,7 @@ export const es: TranslationResources = {
|
|||||||
copyBranchName: "Copiar nombre de sucursal",
|
copyBranchName: "Copiar nombre de sucursal",
|
||||||
rename: "Cambiar nombre del espacio de trabajo",
|
rename: "Cambiar nombre del espacio de trabajo",
|
||||||
archive: "Archivo",
|
archive: "Archivo",
|
||||||
archiveWorktree: "Árbol de trabajo de archivo",
|
archiveWorkspace: "Archivar espacio de trabajo",
|
||||||
hideFromSidebar: "Ocultar de la barra lateral",
|
hideFromSidebar: "Ocultar de la barra lateral",
|
||||||
archiving: "Archivando...",
|
archiving: "Archivando...",
|
||||||
hiding: "Ocultación...",
|
hiding: "Ocultación...",
|
||||||
@@ -889,7 +885,7 @@ export const es: TranslationResources = {
|
|||||||
branchNameCopied: "Nombre de la sucursal copiado",
|
branchNameCopied: "Nombre de la sucursal copiado",
|
||||||
hostDisconnected: "Hostno está conectado",
|
hostDisconnected: "Hostno está conectado",
|
||||||
hideFailed: "No se pudo ocultar el espacio de trabajo",
|
hideFailed: "No se pudo ocultar el espacio de trabajo",
|
||||||
archiveFailed: "No se pudo archivar el árbol de trabajo",
|
archiveFailed: "No se pudo archivar el espacio de trabajo",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1129,7 +1125,8 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "Escriba una ruta de directorio...",
|
placeholder: "Escriba para buscar...",
|
||||||
|
browse: "Explorar…",
|
||||||
opening: "Proyecto de apertura...",
|
opening: "Proyecto de apertura...",
|
||||||
searching: "Buscando...",
|
searching: "Buscando...",
|
||||||
empty: "Comience a escribir una ruta",
|
empty: "Comience a escribir una ruta",
|
||||||
@@ -1646,7 +1643,7 @@ export const es: TranslationResources = {
|
|||||||
openProject: "Abrir proyecto",
|
openProject: "Abrir proyecto",
|
||||||
newWorkspace: "Nuevo espacio de trabajo",
|
newWorkspace: "Nuevo espacio de trabajo",
|
||||||
newWorktree: "Nuevo árbol de trabajo",
|
newWorktree: "Nuevo árbol de trabajo",
|
||||||
archiveWorktree: "Árbol de trabajo de archivo",
|
archiveWorkspace: "Archivar espacio de trabajo",
|
||||||
newTab: "Nueva pestaña",
|
newTab: "Nueva pestaña",
|
||||||
closeCurrentTab: "Cerrar pestaña actual",
|
closeCurrentTab: "Cerrar pestaña actual",
|
||||||
jumpToWorkspace: "Saltar al espacio de trabajo",
|
jumpToWorkspace: "Saltar al espacio de trabajo",
|
||||||
|
|||||||
@@ -625,7 +625,7 @@ export const fr: TranslationResources = {
|
|||||||
success: "Mis à jour",
|
success: "Mis à jour",
|
||||||
},
|
},
|
||||||
archive: {
|
archive: {
|
||||||
label: "Arbre de travail d'archivage",
|
label: "Archiver l’espace de travail",
|
||||||
pending: "Archivage...",
|
pending: "Archivage...",
|
||||||
success: "Archivé",
|
success: "Archivé",
|
||||||
},
|
},
|
||||||
@@ -679,8 +679,6 @@ export const fr: TranslationResources = {
|
|||||||
"La mise à jour n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord",
|
"La mise à jour n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord",
|
||||||
updateCurrent:
|
updateCurrent:
|
||||||
"La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}",
|
"La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}",
|
||||||
archiveNotWorktree:
|
|
||||||
"L'archive n'est pas disponible ici car cet espace de travail n'a pas été créé en tant qu'arbre de travailPaseo",
|
|
||||||
mergePrNoGithub:
|
mergePrNoGithub:
|
||||||
"La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
|
"La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
|
||||||
mergePrMissing:
|
mergePrMissing:
|
||||||
@@ -711,11 +709,9 @@ export const fr: TranslationResources = {
|
|||||||
baseRefUnavailable: "Réf de base indisponible",
|
baseRefUnavailable: "Réf de base indisponible",
|
||||||
failedMerge: "Échec de la fusion",
|
failedMerge: "Échec de la fusion",
|
||||||
failedMergeFromBase: "Échec de la fusion à partir de la base",
|
failedMergeFromBase: "Échec de la fusion à partir de la base",
|
||||||
worktreePathUnavailable: "Chemin d'accès à l'arbre de travail indisponible",
|
|
||||||
failedArchive: "Échec de l'archivage de l'arbre de travail",
|
|
||||||
},
|
},
|
||||||
archiveWarning: {
|
archiveWarning: {
|
||||||
title: "Archiver «{{worktreeName}}»?",
|
title: "Archiver «{{workspaceName}}»?",
|
||||||
confirm: "Archive",
|
confirm: "Archive",
|
||||||
cancel: "Annuler",
|
cancel: "Annuler",
|
||||||
uncommittedChanges: "Modifications non validées",
|
uncommittedChanges: "Modifications non validées",
|
||||||
@@ -865,7 +861,7 @@ export const fr: TranslationResources = {
|
|||||||
copyBranchName: "Copier le nom de la branche",
|
copyBranchName: "Copier le nom de la branche",
|
||||||
rename: "Renommer l'espace de travail",
|
rename: "Renommer l'espace de travail",
|
||||||
archive: "Archive",
|
archive: "Archive",
|
||||||
archiveWorktree: "Arbre de travail d'archivage",
|
archiveWorkspace: "Archiver l’espace de travail",
|
||||||
hideFromSidebar: "Masquer de la barre latérale",
|
hideFromSidebar: "Masquer de la barre latérale",
|
||||||
archiving: "Archivage...",
|
archiving: "Archivage...",
|
||||||
hiding: "Dissimulation...",
|
hiding: "Dissimulation...",
|
||||||
@@ -888,7 +884,7 @@ export const fr: TranslationResources = {
|
|||||||
branchNameCopied: "Nom de la succursale copié",
|
branchNameCopied: "Nom de la succursale copié",
|
||||||
hostDisconnected: "Hostn'est pas connecté",
|
hostDisconnected: "Hostn'est pas connecté",
|
||||||
hideFailed: "Échec du masquage de l'espace de travail",
|
hideFailed: "Échec du masquage de l'espace de travail",
|
||||||
archiveFailed: "Échec de l'archivage de l'arbre de travail",
|
archiveFailed: "Échec de l'archivage de l'espace de travail",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1131,7 +1127,8 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "Tapez un chemin de répertoire...",
|
placeholder: "Tapez pour rechercher...",
|
||||||
|
browse: "Parcourir…",
|
||||||
opening: "Projet d'ouverture...",
|
opening: "Projet d'ouverture...",
|
||||||
searching: "Recherche en cours...",
|
searching: "Recherche en cours...",
|
||||||
empty: "Commencez à taper un chemin",
|
empty: "Commencez à taper un chemin",
|
||||||
@@ -1649,7 +1646,7 @@ export const fr: TranslationResources = {
|
|||||||
openProject: "Projet ouvert",
|
openProject: "Projet ouvert",
|
||||||
newWorkspace: "Nouvel espace de travail",
|
newWorkspace: "Nouvel espace de travail",
|
||||||
newWorktree: "Nouvel arbre de travail",
|
newWorktree: "Nouvel arbre de travail",
|
||||||
archiveWorktree: "Arbre de travail d'archivage",
|
archiveWorkspace: "Archiver l’espace de travail",
|
||||||
newTab: "Nouvel onglet",
|
newTab: "Nouvel onglet",
|
||||||
closeCurrentTab: "Fermer l'onglet actuel",
|
closeCurrentTab: "Fermer l'onglet actuel",
|
||||||
jumpToWorkspace: "Accéder à l'espace de travail",
|
jumpToWorkspace: "Accéder à l'espace de travail",
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ export const ja: TranslationResources = {
|
|||||||
success: "更新しました",
|
success: "更新しました",
|
||||||
},
|
},
|
||||||
archive: {
|
archive: {
|
||||||
label: "ワークツリーをアーカイブ",
|
label: "ワークスペースをアーカイブ",
|
||||||
pending: "アーカイブ中...",
|
pending: "アーカイブ中...",
|
||||||
success: "アーカイブしました",
|
success: "アーカイブしました",
|
||||||
},
|
},
|
||||||
@@ -670,8 +670,6 @@ export const ja: TranslationResources = {
|
|||||||
updateDirty:
|
updateDirty:
|
||||||
"ローカルに変更があるため更新は利用できません。先にコミットまたはスタッシュしてください",
|
"ローカルに変更があるため更新は利用できません。先にコミットまたはスタッシュしてください",
|
||||||
updateCurrent: "このブランチはすでに{{baseRef}}と最新の状態のため、更新は利用できません",
|
updateCurrent: "このブランチはすでに{{baseRef}}と最新の状態のため、更新は利用できません",
|
||||||
archiveNotWorktree:
|
|
||||||
"このワークスペースはPaseoワークツリーとして作成されていないため、アーカイブはここでは利用できません",
|
|
||||||
mergePrNoGithub: "GitHubが接続されていないため、PRのマージは現在利用できません",
|
mergePrNoGithub: "GitHubが接続されていないため、PRのマージは現在利用できません",
|
||||||
mergePrMissing: "プルリクエストがまだないため、PRのマージは利用できません",
|
mergePrMissing: "プルリクエストがまだないため、PRのマージは利用できません",
|
||||||
mergePrDraft: "プルリクエストがまだドラフトのため、PRのマージは利用できません",
|
mergePrDraft: "プルリクエストがまだドラフトのため、PRのマージは利用できません",
|
||||||
@@ -697,11 +695,9 @@ export const ja: TranslationResources = {
|
|||||||
baseRefUnavailable: "ベースRefが利用できません",
|
baseRefUnavailable: "ベースRefが利用できません",
|
||||||
failedMerge: "マージに失敗しました",
|
failedMerge: "マージに失敗しました",
|
||||||
failedMergeFromBase: "ベースからのマージに失敗しました",
|
failedMergeFromBase: "ベースからのマージに失敗しました",
|
||||||
worktreePathUnavailable: "ワークツリーパスが利用できません",
|
|
||||||
failedArchive: "ワークツリーのアーカイブに失敗しました",
|
|
||||||
},
|
},
|
||||||
archiveWarning: {
|
archiveWarning: {
|
||||||
title: '"{{worktreeName}}"をアーカイブしますか?',
|
title: '"{{workspaceName}}"をアーカイブしますか?',
|
||||||
confirm: "アーカイブ",
|
confirm: "アーカイブ",
|
||||||
cancel: "キャンセル",
|
cancel: "キャンセル",
|
||||||
uncommittedChanges: "未コミットの変更",
|
uncommittedChanges: "未コミットの変更",
|
||||||
@@ -851,7 +847,7 @@ export const ja: TranslationResources = {
|
|||||||
copyBranchName: "ブランチ名をコピー",
|
copyBranchName: "ブランチ名をコピー",
|
||||||
rename: "ワークスペースの名前を変更",
|
rename: "ワークスペースの名前を変更",
|
||||||
archive: "アーカイブ",
|
archive: "アーカイブ",
|
||||||
archiveWorktree: "ワークツリーをアーカイブ",
|
archiveWorkspace: "ワークスペースをアーカイブ",
|
||||||
hideFromSidebar: "サイドバーから非表示",
|
hideFromSidebar: "サイドバーから非表示",
|
||||||
archiving: "アーカイブ中...",
|
archiving: "アーカイブ中...",
|
||||||
hiding: "非表示にしています...",
|
hiding: "非表示にしています...",
|
||||||
@@ -874,7 +870,7 @@ export const ja: TranslationResources = {
|
|||||||
branchNameCopied: "ブランチ名をコピーしました",
|
branchNameCopied: "ブランチ名をコピーしました",
|
||||||
hostDisconnected: "ホストが接続されていません",
|
hostDisconnected: "ホストが接続されていません",
|
||||||
hideFailed: "ワークスペースの非表示に失敗しました",
|
hideFailed: "ワークスペースの非表示に失敗しました",
|
||||||
archiveFailed: "ワークツリーのアーカイブに失敗しました",
|
archiveFailed: "ワークスペースのアーカイブに失敗しました",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1107,7 +1103,8 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "ディレクトリパスを入力...",
|
placeholder: "入力して検索...",
|
||||||
|
browse: "参照…",
|
||||||
opening: "プロジェクトを開いています...",
|
opening: "プロジェクトを開いています...",
|
||||||
searching: "検索中...",
|
searching: "検索中...",
|
||||||
empty: "パスを入力してください",
|
empty: "パスを入力してください",
|
||||||
@@ -1621,7 +1618,7 @@ export const ja: TranslationResources = {
|
|||||||
openProject: "プロジェクトを開く",
|
openProject: "プロジェクトを開く",
|
||||||
newWorkspace: "新しいワークスペース",
|
newWorkspace: "新しいワークスペース",
|
||||||
newWorktree: "新しいワークツリー",
|
newWorktree: "新しいワークツリー",
|
||||||
archiveWorktree: "ワークツリーをアーカイブ",
|
archiveWorkspace: "ワークスペースをアーカイブ",
|
||||||
newTab: "新しいタブ",
|
newTab: "新しいタブ",
|
||||||
closeCurrentTab: "現在のタブを閉じる",
|
closeCurrentTab: "現在のタブを閉じる",
|
||||||
jumpToWorkspace: "ワークスペースにジャンプ",
|
jumpToWorkspace: "ワークスペースにジャンプ",
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ export const ptBR: TranslationResources = {
|
|||||||
success: "Atualizado",
|
success: "Atualizado",
|
||||||
},
|
},
|
||||||
archive: {
|
archive: {
|
||||||
label: "Arquivar worktree",
|
label: "Arquivar workspace",
|
||||||
pending: "Arquivando...",
|
pending: "Arquivando...",
|
||||||
success: "Arquivado",
|
success: "Arquivado",
|
||||||
},
|
},
|
||||||
@@ -675,8 +675,6 @@ export const ptBR: TranslationResources = {
|
|||||||
"Atualizar não está disponível enquanto há alterações locais. Faça commit ou stash primeiro",
|
"Atualizar não está disponível enquanto há alterações locais. Faça commit ou stash primeiro",
|
||||||
updateCurrent:
|
updateCurrent:
|
||||||
"Atualizar não está disponível porque esta branch já está atualizada com {{baseRef}}",
|
"Atualizar não está disponível porque esta branch já está atualizada com {{baseRef}}",
|
||||||
archiveNotWorktree:
|
|
||||||
"Arquivar não está disponível aqui porque este workspace não foi criado como um worktree do Paseo",
|
|
||||||
mergePrNoGithub:
|
mergePrNoGithub:
|
||||||
"Merge da PR não está disponível agora porque o GitHub não está conectado",
|
"Merge da PR não está disponível agora porque o GitHub não está conectado",
|
||||||
mergePrMissing: "Merge da PR não está disponível porque ainda não há uma pull request",
|
mergePrMissing: "Merge da PR não está disponível porque ainda não há uma pull request",
|
||||||
@@ -703,11 +701,9 @@ export const ptBR: TranslationResources = {
|
|||||||
baseRefUnavailable: "Ref base indisponível",
|
baseRefUnavailable: "Ref base indisponível",
|
||||||
failedMerge: "Falha ao fazer merge",
|
failedMerge: "Falha ao fazer merge",
|
||||||
failedMergeFromBase: "Falha ao fazer merge da base",
|
failedMergeFromBase: "Falha ao fazer merge da base",
|
||||||
worktreePathUnavailable: "Caminho do worktree indisponível",
|
|
||||||
failedArchive: "Falha ao arquivar worktree",
|
|
||||||
},
|
},
|
||||||
archiveWarning: {
|
archiveWarning: {
|
||||||
title: 'Arquivar "{{worktreeName}}"?',
|
title: 'Arquivar "{{workspaceName}}"?',
|
||||||
confirm: "Arquivar",
|
confirm: "Arquivar",
|
||||||
cancel: "Cancelar",
|
cancel: "Cancelar",
|
||||||
uncommittedChanges: "Alterações sem commit",
|
uncommittedChanges: "Alterações sem commit",
|
||||||
@@ -857,7 +853,7 @@ export const ptBR: TranslationResources = {
|
|||||||
copyBranchName: "Copiar nome da branch",
|
copyBranchName: "Copiar nome da branch",
|
||||||
rename: "Renomear workspace",
|
rename: "Renomear workspace",
|
||||||
archive: "Arquivar",
|
archive: "Arquivar",
|
||||||
archiveWorktree: "Arquivar worktree",
|
archiveWorkspace: "Arquivar workspace",
|
||||||
hideFromSidebar: "Ocultar da barra lateral",
|
hideFromSidebar: "Ocultar da barra lateral",
|
||||||
archiving: "Arquivando...",
|
archiving: "Arquivando...",
|
||||||
hiding: "Ocultando...",
|
hiding: "Ocultando...",
|
||||||
@@ -880,7 +876,7 @@ export const ptBR: TranslationResources = {
|
|||||||
branchNameCopied: "Nome da branch copiado",
|
branchNameCopied: "Nome da branch copiado",
|
||||||
hostDisconnected: "Host não está conectado",
|
hostDisconnected: "Host não está conectado",
|
||||||
hideFailed: "Falha ao ocultar workspace",
|
hideFailed: "Falha ao ocultar workspace",
|
||||||
archiveFailed: "Falha ao arquivar worktree",
|
archiveFailed: "Falha ao arquivar workspace",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1115,7 +1111,8 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "Digite um caminho de diretório...",
|
placeholder: "Digite para pesquisar...",
|
||||||
|
browse: "Procurar…",
|
||||||
opening: "Abrindo projeto...",
|
opening: "Abrindo projeto...",
|
||||||
searching: "Buscando...",
|
searching: "Buscando...",
|
||||||
empty: "Comece digitando um caminho",
|
empty: "Comece digitando um caminho",
|
||||||
@@ -1630,7 +1627,7 @@ export const ptBR: TranslationResources = {
|
|||||||
openProject: "Abrir projeto",
|
openProject: "Abrir projeto",
|
||||||
newWorkspace: "Novo workspace",
|
newWorkspace: "Novo workspace",
|
||||||
newWorktree: "Novo worktree",
|
newWorktree: "Novo worktree",
|
||||||
archiveWorktree: "Arquivar worktree",
|
archiveWorkspace: "Arquivar workspace",
|
||||||
newTab: "Nova aba",
|
newTab: "Nova aba",
|
||||||
closeCurrentTab: "Fechar aba atual",
|
closeCurrentTab: "Fechar aba atual",
|
||||||
jumpToWorkspace: "Ir para workspace",
|
jumpToWorkspace: "Ir para workspace",
|
||||||
|
|||||||
@@ -624,7 +624,7 @@ export const ru: TranslationResources = {
|
|||||||
success: "Обновлено",
|
success: "Обновлено",
|
||||||
},
|
},
|
||||||
archive: {
|
archive: {
|
||||||
label: "Архив рабочего дерева",
|
label: "Архивировать рабочее пространство",
|
||||||
pending: "Архивирование...",
|
pending: "Архивирование...",
|
||||||
success: "В архиве",
|
success: "В архиве",
|
||||||
},
|
},
|
||||||
@@ -675,8 +675,6 @@ export const ru: TranslationResources = {
|
|||||||
"Обновление недоступно, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.",
|
"Обновление недоступно, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.",
|
||||||
updateCurrent:
|
updateCurrent:
|
||||||
"Обновление недоступно, поскольку эта ветка уже обновлена до версии{{baseRef}}.",
|
"Обновление недоступно, поскольку эта ветка уже обновлена до версии{{baseRef}}.",
|
||||||
archiveNotWorktree:
|
|
||||||
"Архив здесь недоступен, поскольку это рабочее пространство не было создано как рабочее дерево Paseo.",
|
|
||||||
mergePrNoGithub: "Объединение PR сейчас недоступно, поскольку GitHub не подключен.",
|
mergePrNoGithub: "Объединение PR сейчас недоступно, поскольку GitHub не подключен.",
|
||||||
mergePrMissing: "Объединение PR недоступно, поскольку еще нет запроса на включение",
|
mergePrMissing: "Объединение PR недоступно, поскольку еще нет запроса на включение",
|
||||||
mergePrDraft:
|
mergePrDraft:
|
||||||
@@ -704,11 +702,9 @@ export const ru: TranslationResources = {
|
|||||||
baseRefUnavailable: "Базовый номер недоступен.",
|
baseRefUnavailable: "Базовый номер недоступен.",
|
||||||
failedMerge: "Не удалось объединиться",
|
failedMerge: "Не удалось объединиться",
|
||||||
failedMergeFromBase: "Не удалось объединиться с базой.",
|
failedMergeFromBase: "Не удалось объединиться с базой.",
|
||||||
worktreePathUnavailable: "Путь к рабочему дереву недоступен.",
|
|
||||||
failedArchive: "Не удалось заархивировать рабочее дерево.",
|
|
||||||
},
|
},
|
||||||
archiveWarning: {
|
archiveWarning: {
|
||||||
title: 'Архив "{{worktreeName}}"?',
|
title: 'Архив "{{workspaceName}}"?',
|
||||||
confirm: "Архив",
|
confirm: "Архив",
|
||||||
cancel: "Отмена",
|
cancel: "Отмена",
|
||||||
uncommittedChanges: "Незафиксированные изменения",
|
uncommittedChanges: "Незафиксированные изменения",
|
||||||
@@ -858,7 +854,7 @@ export const ru: TranslationResources = {
|
|||||||
copyBranchName: "Скопировать название ветки",
|
copyBranchName: "Скопировать название ветки",
|
||||||
rename: "Переименовать рабочую область",
|
rename: "Переименовать рабочую область",
|
||||||
archive: "Архив",
|
archive: "Архив",
|
||||||
archiveWorktree: "Архив рабочего дерева",
|
archiveWorkspace: "Архивировать рабочее пространство",
|
||||||
hideFromSidebar: "Скрыть с боковой панели",
|
hideFromSidebar: "Скрыть с боковой панели",
|
||||||
archiving: "Архивирование...",
|
archiving: "Архивирование...",
|
||||||
hiding: "Скрытие...",
|
hiding: "Скрытие...",
|
||||||
@@ -881,7 +877,7 @@ export const ru: TranslationResources = {
|
|||||||
branchNameCopied: "Название филиала скопировано.",
|
branchNameCopied: "Название филиала скопировано.",
|
||||||
hostDisconnected: "Host не подключен",
|
hostDisconnected: "Host не подключен",
|
||||||
hideFailed: "Не удалось скрыть рабочую область.",
|
hideFailed: "Не удалось скрыть рабочую область.",
|
||||||
archiveFailed: "Не удалось заархивировать рабочее дерево.",
|
archiveFailed: "Не удалось заархивировать рабочее пространство.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1119,7 +1115,8 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "Введите путь к каталогу...",
|
placeholder: "Введите текст для поиска...",
|
||||||
|
browse: "Обзор…",
|
||||||
opening: "Открытие проекта...",
|
opening: "Открытие проекта...",
|
||||||
searching: "Идет поиск...",
|
searching: "Идет поиск...",
|
||||||
empty: "Начните вводить путь",
|
empty: "Начните вводить путь",
|
||||||
@@ -1638,7 +1635,7 @@ export const ru: TranslationResources = {
|
|||||||
openProject: "Открыть проект",
|
openProject: "Открыть проект",
|
||||||
newWorkspace: "Новое рабочее пространство",
|
newWorkspace: "Новое рабочее пространство",
|
||||||
newWorktree: "Новое рабочее дерево",
|
newWorktree: "Новое рабочее дерево",
|
||||||
archiveWorktree: "Архив рабочего дерева",
|
archiveWorkspace: "Архивировать рабочее пространство",
|
||||||
newTab: "Новая вкладка",
|
newTab: "Новая вкладка",
|
||||||
closeCurrentTab: "Закрыть текущую вкладку",
|
closeCurrentTab: "Закрыть текущую вкладку",
|
||||||
jumpToWorkspace: "Перейти в рабочую область",
|
jumpToWorkspace: "Перейти в рабочую область",
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ export const zhCN: TranslationResources = {
|
|||||||
success: "已更新",
|
success: "已更新",
|
||||||
},
|
},
|
||||||
archive: {
|
archive: {
|
||||||
label: "归档 worktree",
|
label: "归档工作区",
|
||||||
pending: "正在归档...",
|
pending: "正在归档...",
|
||||||
success: "已归档",
|
success: "已归档",
|
||||||
},
|
},
|
||||||
@@ -656,7 +656,6 @@ export const zhCN: TranslationResources = {
|
|||||||
updateNoBase: "无法更新,因为无法确定 base branch",
|
updateNoBase: "无法更新,因为无法确定 base branch",
|
||||||
updateDirty: "有本地变更时无法更新,请先 commit 或 stash",
|
updateDirty: "有本地变更时无法更新,请先 commit 或 stash",
|
||||||
updateCurrent: "无法更新,因为此分支已与 {{baseRef}} 保持最新",
|
updateCurrent: "无法更新,因为此分支已与 {{baseRef}} 保持最新",
|
||||||
archiveNotWorktree: "此处无法归档,因为此 workspace 不是作为 Paseo worktree 创建的",
|
|
||||||
mergePrNoGithub: "当前无法 merge PR,因为 GitHub 未连接",
|
mergePrNoGithub: "当前无法 merge PR,因为 GitHub 未连接",
|
||||||
mergePrMissing: "无法 merge PR,因为还没有 pull request",
|
mergePrMissing: "无法 merge PR,因为还没有 pull request",
|
||||||
mergePrDraft: "无法 merge PR,因为 pull request 仍是 draft",
|
mergePrDraft: "无法 merge PR,因为 pull request 仍是 draft",
|
||||||
@@ -679,11 +678,9 @@ export const zhCN: TranslationResources = {
|
|||||||
baseRefUnavailable: "Base ref 不可用",
|
baseRefUnavailable: "Base ref 不可用",
|
||||||
failedMerge: "Merge 失败",
|
failedMerge: "Merge 失败",
|
||||||
failedMergeFromBase: "从 base merge 失败",
|
failedMergeFromBase: "从 base merge 失败",
|
||||||
worktreePathUnavailable: "Worktree 路径不可用",
|
|
||||||
failedArchive: "归档 worktree 失败",
|
|
||||||
},
|
},
|
||||||
archiveWarning: {
|
archiveWarning: {
|
||||||
title: "归档「{{worktreeName}}」?",
|
title: "归档「{{workspaceName}}」?",
|
||||||
confirm: "归档",
|
confirm: "归档",
|
||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
uncommittedChanges: "未 commit 的变更",
|
uncommittedChanges: "未 commit 的变更",
|
||||||
@@ -831,7 +828,7 @@ export const zhCN: TranslationResources = {
|
|||||||
copyBranchName: "复制分支名称",
|
copyBranchName: "复制分支名称",
|
||||||
rename: "重命名 workspace",
|
rename: "重命名 workspace",
|
||||||
archive: "归档",
|
archive: "归档",
|
||||||
archiveWorktree: "归档 worktree",
|
archiveWorkspace: "归档工作区",
|
||||||
hideFromSidebar: "从侧边栏隐藏",
|
hideFromSidebar: "从侧边栏隐藏",
|
||||||
archiving: "正在归档...",
|
archiving: "正在归档...",
|
||||||
hiding: "正在隐藏...",
|
hiding: "正在隐藏...",
|
||||||
@@ -853,7 +850,7 @@ export const zhCN: TranslationResources = {
|
|||||||
branchNameCopied: "分支名称已复制",
|
branchNameCopied: "分支名称已复制",
|
||||||
hostDisconnected: "Host 未连接",
|
hostDisconnected: "Host 未连接",
|
||||||
hideFailed: "隐藏 workspace 失败",
|
hideFailed: "隐藏 workspace 失败",
|
||||||
archiveFailed: "归档 worktree 失败",
|
archiveFailed: "归档工作区失败",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1078,7 +1075,8 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
projectPicker: {
|
projectPicker: {
|
||||||
placeholder: "输入目录路径...",
|
placeholder: "输入以搜索...",
|
||||||
|
browse: "浏览…",
|
||||||
opening: "正在打开 project...",
|
opening: "正在打开 project...",
|
||||||
searching: "正在搜索...",
|
searching: "正在搜索...",
|
||||||
empty: "开始输入路径",
|
empty: "开始输入路径",
|
||||||
@@ -1585,7 +1583,7 @@ export const zhCN: TranslationResources = {
|
|||||||
openProject: "打开项目",
|
openProject: "打开项目",
|
||||||
newWorkspace: "新建 workspace",
|
newWorkspace: "新建 workspace",
|
||||||
newWorktree: "新建 worktree",
|
newWorktree: "新建 worktree",
|
||||||
archiveWorktree: "归档 worktree",
|
archiveWorkspace: "归档工作区",
|
||||||
newTab: "新建标签",
|
newTab: "新建标签",
|
||||||
closeCurrentTab: "关闭当前标签",
|
closeCurrentTab: "关闭当前标签",
|
||||||
jumpToWorkspace: "跳转到 workspace",
|
jumpToWorkspace: "跳转到 workspace",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export type KeyboardActionId =
|
|||||||
| "workspace.terminal.new"
|
| "workspace.terminal.new"
|
||||||
| "workspace.new"
|
| "workspace.new"
|
||||||
| "worktree.new"
|
| "worktree.new"
|
||||||
| "worktree.archive"
|
| "workspace.archive"
|
||||||
| "view.toggle.focus"
|
| "view.toggle.focus"
|
||||||
| "theme.cycle"
|
| "theme.cycle"
|
||||||
| "message-input.action";
|
| "message-input.action";
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export type KeyboardActionId =
|
|||||||
| "sidebar.toggle.right"
|
| "sidebar.toggle.right"
|
||||||
| "workspace.new"
|
| "workspace.new"
|
||||||
| "worktree.new"
|
| "worktree.new"
|
||||||
| "worktree.archive";
|
| "workspace.archive";
|
||||||
|
|
||||||
export type KeyboardActionDefinition =
|
export type KeyboardActionDefinition =
|
||||||
| { id: "agent.interrupt"; scope: KeyboardActionScope }
|
| { id: "agent.interrupt"; scope: KeyboardActionScope }
|
||||||
@@ -60,7 +60,7 @@ export type KeyboardActionDefinition =
|
|||||||
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.new"; scope: KeyboardActionScope }
|
| { id: "workspace.new"; scope: KeyboardActionScope }
|
||||||
| { id: "worktree.new"; scope: KeyboardActionScope }
|
| { id: "worktree.new"; scope: KeyboardActionScope }
|
||||||
| { id: "worktree.archive"; scope: KeyboardActionScope };
|
| { id: "workspace.archive"; scope: KeyboardActionScope };
|
||||||
|
|
||||||
export interface KeyboardActionHandler {
|
export interface KeyboardActionHandler {
|
||||||
handlerId: string;
|
handlerId: string;
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
|
|||||||
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||||
"new-agent": "settings.shortcuts.help.openProject",
|
"new-agent": "settings.shortcuts.help.openProject",
|
||||||
"new-workspace": "settings.shortcuts.help.newWorkspace",
|
"new-workspace": "settings.shortcuts.help.newWorkspace",
|
||||||
"archive-worktree": "settings.shortcuts.help.archiveWorktree",
|
"archive-workspace": "settings.shortcuts.help.archiveWorkspace",
|
||||||
"workspace-tab-new": "settings.shortcuts.help.newTab",
|
"workspace-tab-new": "settings.shortcuts.help.newTab",
|
||||||
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
|
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
|
||||||
"workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace",
|
"workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace",
|
||||||
@@ -221,28 +221,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Archive worktree ---
|
// --- Archive workspace ---
|
||||||
{
|
{
|
||||||
|
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
|
||||||
|
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
|
||||||
id: "worktree-archive-cmd-shift-backspace-mac",
|
id: "worktree-archive-cmd-shift-backspace-mac",
|
||||||
action: "worktree.archive",
|
action: "workspace.archive",
|
||||||
combo: "Cmd+Shift+Backspace",
|
combo: "Cmd+Shift+Backspace",
|
||||||
when: { mac: true, commandCenter: false },
|
when: { mac: true, commandCenter: false },
|
||||||
help: {
|
help: {
|
||||||
id: "archive-worktree",
|
id: "archive-workspace",
|
||||||
section: "projects",
|
section: "projects",
|
||||||
label: "Archive worktree",
|
label: "Archive workspace",
|
||||||
keys: ["mod", "shift", "Backspace"],
|
keys: ["mod", "shift", "Backspace"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
|
||||||
|
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
|
||||||
id: "worktree-archive-ctrl-shift-backspace-non-mac",
|
id: "worktree-archive-ctrl-shift-backspace-non-mac",
|
||||||
action: "worktree.archive",
|
action: "workspace.archive",
|
||||||
combo: "Ctrl+Shift+Backspace",
|
combo: "Ctrl+Shift+Backspace",
|
||||||
when: { mac: false, commandCenter: false, terminal: false },
|
when: { mac: false, commandCenter: false, terminal: false },
|
||||||
help: {
|
help: {
|
||||||
id: "archive-worktree",
|
id: "archive-workspace",
|
||||||
section: "projects",
|
section: "projects",
|
||||||
label: "Archive worktree",
|
label: "Archive workspace",
|
||||||
keys: ["mod", "shift", "Backspace"],
|
keys: ["mod", "shift", "Backspace"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user