diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index f6f2c0bd13c..ab1c6ce687b 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -29,6 +29,9 @@ on: - 'electron.vite.config.ts' - 'config/build-plugins/**' - 'src/main/daemon/**' + - 'src/main/providers/local-pty-provider.ts' + - 'src/main/providers/windows-shell-args.ts' + - 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts' - 'native/computer-use-macos/**' - 'native/computer-use-linux/**' - 'native/computer-use-windows/**' @@ -106,6 +109,7 @@ jobs: src/main/computer/macos-native-provider-socket.test.ts src/main/computer/macos-computer-use-permissions.test.ts src/main/computer/macos-computer-use-permission-status.test.ts + src/main/providers/windows-shell-preflight-runtime.windows.test.ts src/main/computer/desktop-script-provider-client.test.ts src/main/computer/desktop-script-provider-cache.test.ts src/main/computer/desktop-script-provider-actions.test.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 4375803952e..c1421ba2499 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -234,7 +234,6 @@ inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts -inline src/renderer/src/components/sidebar/WorktreeList.tsx inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts inline src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -259,7 +258,6 @@ inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts -inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx inline src/renderer/src/hooks/useAutomationDispatchEvents.ts inline src/renderer/src/hooks/useComposerState.ts inline src/renderer/src/hooks/useEditorExternalWatch.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 45c7aed8242..6d7292f3bf3 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1452,6 +1452,181 @@ ], "demotionRule": "Quarantine the Electron journey only with a linked product or harness defect; demote if activation changes the owner/runtime/daemon/PTY identity, loses prior output, opens before provider readiness, or fails to honor a committed quit." }, + { + "id": "runtime.renderer-graph-reload-termination", + "title": "Renderer graph reloads terminate under exact document and window authority", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "electron-runtime-contract", + "surfaces": [ + "desktop renderer reload and crash recovery", + "headless-to-desktop promotion", + "CLI and paired-runtime graph availability" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "paired-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon", "paired-runtime"], + "coverageNotes": "Deterministic lifecycle, IPC, window-notification, and runtime ownership tests cover generation replacement, stale callbacks, same-frame old-document publication, blocked external navigation, cancelled renderer navigation, concurrent provisional starts, process loss, timeout cleanup, headless fallback, and competing windows. Isolated macOS Electron validation covers cancelled navigation, forced renderer recovery, and headless serve promotion with live daemon-backed PTY identity and I/O. Linux, Windows, WSL, SSH promotion, and mixed-version desktop/server live runs remain uncollected; the renderer generation stays local to Electron IPC and does not change the remote wire.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/14066", + "https://linear.app/stably/issue/STA-4016", + "https://github.com/stablyai/orca/pull/14070" + ], + "invariant": "Every genuine renderer graph reload revision settles exactly once, while a blocked external navigation that preserves the current document never starts a revision. A cancelled renderer-document navigation restores authority only when that same renderer survived; cancellation after an earlier document committed remains fenced until publication or timeout. Replacing a reload cancels only the prior revision; its timer and callbacks cannot settle a newer revision. A desktop reload succeeds only from a publication carrying a new renderer-document generation, while a failed headless promotion may recover only from its originating window. Timeout, renderer loss, and close reach a terminal graph state, and successful desktop publication permanently retires headless sentinel fallback until a new headless runtime is established.", + "oracle": "Start a blocked external main-frame navigation and require the current renderer graph and notification path to remain authoritative. Start a same-renderer navigation, cancel it before commit, and require the renderer canary and ready authority to survive; coalesce overlapping provisional starts into one fence, and keep an earlier committed reload fenced if a later navigation is cancelled. Drive two genuine reloads half a timeout apart and require the first deadline to leave the second revision reloading while the second deadline alone makes it unavailable. During the second revision publish from the prior renderer document through the still-current main frame and require rejection without graph mutation or timeout cancellation. Restore headless authority after failed promotion, publish from a competing window, and require rejection while the sentinel remains authoritative. Then force a real renderer crash and separately promote an isolated headless serve owner; require runtime graph readiness, stable runtime/main/daemon/PTY identities, recovered visible terminal output, and post-recovery input/output.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/runtime-graph-reload-lifecycle.test.ts src/main/window/runtime-renderer-notification-sender.test.ts src/main/window/renderer-document-navigation.test.ts src/main/ipc/runtime.test.ts src/main/window/attach-main-window-services.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts", + "pnpm exec electron-vite build --mode e2e", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/renderer-blocked-navigation-graph-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "SKIP_BUILD=1 pnpm exec playwright test e2e/renderer-crash-recovery-terminal-input.spec.ts tests/e2e/renderer-crash-recovery-terminal-input.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "SKIP_BUILD=1 pnpm exec playwright test e2e/headless-serve-desktop-activation.spec.ts tests/e2e/headless-serve-desktop-activation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/main/runtime/runtime-graph-reload-lifecycle.test.ts", + "src/main/window/runtime-renderer-notification-sender.test.ts", + "src/main/window/renderer-document-navigation.test.ts", + "src/main/ipc/runtime.test.ts", + "src/main/window/attach-main-window-services.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts", + "src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts", + "tests/e2e/renderer-blocked-navigation-graph-authority.spec.ts", + "tests/e2e/renderer-crash-recovery-terminal-input.spec.ts", + "tests/e2e/headless-serve-desktop-activation.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/runtime-graph-reload-lifecycle.test.ts", + "assertions": [ + "each revision settles exactly once", + "replacement cancels the old revision and fences its timeout", + "settlement and timeout release their timers" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "only the newest reload owns the terminal timeout", + "a superseded renderer-document generation cannot settle a reload", + "cancelled navigation restores only the renderer or headless authority that actually survived", + "cancellation after an earlier document commit stays fenced until its restarted timeout", + "failed headless promotion remains pinned to its originating window", + "successful desktop publication retires headless fallback" + ] + }, + { + "file": "src/main/ipc/runtime.test.ts", + "assertions": [ + "graph publication requires a non-empty renderer generation", + "only the current BrowserWindow main frame may publish" + ] + }, + { + "file": "src/main/window/runtime-renderer-notification-sender.test.ts", + "assertions": [ + "renderer failure is contained once per load generation", + "late events cannot revive a closed sender" + ] + }, + { + "file": "src/main/window/renderer-document-navigation.test.ts", + "assertions": [ + "blocked external navigation does not start a renderer generation", + "only the packaged renderer file or development origin starts a renderer generation", + "concurrent provisional starts share one reload fence and cancel only after every attempt fails" + ] + }, + { + "file": "tests/e2e/renderer-blocked-navigation-graph-authority.spec.ts", + "assertions": [ + "blocked external navigation starts and stops loading without replacing the renderer document", + "runtime graph epoch, readiness, and authoritative window remain unchanged", + "a later listener can cancel a renderer navigation while its canary and authoritative graph recover" + ] + }, + { + "file": "tests/e2e/renderer-crash-recovery-terminal-input.spec.ts", + "assertions": [ + "main observes renderer process loss and a completed recovery load", + "the same daemon-backed PTY remains reachable through transport and direct-write probes" + ] + }, + { + "file": "tests/e2e/headless-serve-desktop-activation.spec.ts", + "assertions": [ + "promotion preserves main owner, runtime, daemon, and PTY identity", + "terminal output survives promotion and post-promotion input/output remains live" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-14", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/runtime-graph-reload-lifecycle.test.ts src/main/window/runtime-renderer-notification-sender.test.ts src/main/window/renderer-document-navigation.test.ts src/main/ipc/runtime.test.ts src/main/window/attach-main-window-services.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts", + "result": "passed", + "durationSeconds": 12, + "summary": "Eight focused files passed with 1,175 tests and one pre-existing skip after the merge from latest main. The suite covers exact reload settlement, timeout replacement, cancelled-navigation recovery, concurrent provisional starts, document and window authority, notification containment, preload IPC, and renderer publication scheduling." + }, + { + "date": "2026-08-14", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/renderer-blocked-navigation-graph-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 8, + "summary": "Two isolated Electron flows passed: blocked external navigation left epoch and authority unchanged, and a same-renderer navigation cancelled by a later listener preserved its canary while returning the incremented graph generation to ready under the same authoritative window." + }, + { + "date": "2026-08-13", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test e2e/renderer-crash-recovery-terminal-input.spec.ts tests/e2e/renderer-crash-recovery-terminal-input.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 18, + "summary": "Two forced renderer process deaths each produced a main-observed recovery load; the same daemon-backed PTY remained live through renderer transport and direct-write probes." + }, + { + "date": "2026-08-13", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test e2e/headless-serve-desktop-activation.spec.ts tests/e2e/headless-serve-desktop-activation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 10, + "summary": "The isolated headless serve owner promoted to a real renderer while preserving main owner, runtime, daemon, and PTY identity. Pre-promotion output remained visible and post-promotion terminal input/output succeeded; a full-window proof image records both markers." + } + ], + "runtimeBudget": { + "p95Seconds": 420, + "scope": "focused deterministic contracts plus isolated forced-crash and headless-promotion Electron journeys" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Fresh deterministic and two isolated Electron journeys are green; CI soak history is pending." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "A byte-identical three-case oracle was rerun on 2026-08-13. Latest main ee8dd4796e failed all three: reload remained reloading after 22.5 seconds, the prior renderer document settled the replacement, and a competing window stole failed-promotion authority. Submitted PR head feeca6c22f passed the bounded replacement timeout but failed both authority cases. The locally rewritten candidate passed all three. Disabling the local document-generation and pending-window fences reproduces the submitted-head failures; disabling the cancelled-navigation classifier leaves the graph reloading and increments its epoch in the new live oracle." + }, + "performanceBudget": { + "required": true, + "evidence": "Fresh delegated audit passed with no blocking, major, or actionable minor findings. The candidate retains at most one unref'd timer, reuses the existing renderer document UUID, adds about 67 serialized bytes to each already-coalesced local graph IPC, and performs constant-time generation/window checks. It adds no polling, subprocess, network request, remote frame, provider scan, or steady-state fanout; a dedicated benchmark would measure existing graph/structured-clone noise rather than meaningful incremental work." + }, + "promotionCriteria": [ + "Collect CI and soak history without unexplained flakes.", + "Collect live Linux and Windows desktop reload evidence.", + "Add live SSH or WSL promotion evidence if provider-specific graph restoration diverges." + ], + "knownGaps": [ + "The live journeys cover macOS and daemon-backed local terminals, not Linux desktop, Windows, WSL, or SSH promotion.", + "The same-frame stale-document race is deterministic fault injection because Playwright cannot schedule an invoke precisely between did-start-loading and frame replacement.", + "No mixed-version live run is required because rendererGeneration is confined to same-version preload/main Electron IPC and the remote runtime wire is unchanged." + ], + "demotionRule": "Demote if a reload revision settles more than once, a stale document or competing window can publish, a timer survives settlement, desktop failure stays indefinitely reloading, headless fallback returns after successful promotion, or either isolated Electron journey loses runtime or PTY identity." + }, { "id": "runtime.websocket-heartbeat-cadence", "title": "Runtime WebSockets enter an owned shared heartbeat cadence", @@ -8470,10 +8645,14 @@ "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local"], - "coverageNotes": "Local macOS evidence covers the runtime PTY write contract, a live dev-runtime CLI repro, and a Haiku composer trace. The Claude render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.", - "motivatingLinks": ["https://github.com/stablyai/orca/issues/7226"], - "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Claude must emit a post-paste composer marker and then settle, or reach the bounded fallback first.", - "oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, and a Claude-shaped multi-frame render where the legacy 500 ms delay and first show-cursor marker are both premature. The candidate waits for post-marker output quiescence before one submit, resets that window on later frames, and still submits once at the hard deadline if output never settles. Orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.", + "coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/7226", + "https://github.com/stablyai/orca/issues/13821", + "https://github.com/stablyai/orca/issues/14347" + ], + "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; a late marker receives a fresh settlement window.", + "oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, and Claude- and Codex-shaped multi-frame renders where the legacy platform delay and first show-cursor marker are both premature. The candidate waits for post-marker output quiescence before one submit, resets that window on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. Orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts", "node tests/tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000" @@ -8499,7 +8678,8 @@ "file": "src/main/runtime/orca-runtime.test.ts", "assertions": [ "runtime writes bracketed paste before a delayed submit", - "Claude ignores unrelated and split pre-marker output, then waits for post-marker render quiescence before one submit", + "Claude and Codex ignore unrelated and split pre-marker output, then wait for post-marker render quiescence before one submit", + "a late Codex marker receives a fresh settlement window before one submit", "a silent Claude composer reaches one bounded fallback submit", "a marked Claude composer with continuous render output reaches one bounded fallback submit", "large prompt frames are chunked and reconstructed before submit", @@ -8531,6 +8711,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-14", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts", + "result": "passed", + "durationSeconds": 11.32, + "summary": "4 files and 1,303 tests passed with one skipped. Claude and Codex both wait for post-marker quiescence, and a Codex marker arriving at 7.9 seconds receives a fresh window through its final slow frame. Exact-build live Codex workers accepted injected prompts without manual Enter, replied, called worker_done, and settled successfully in the rendered Electron UI." + }, { "date": "2026-08-13", "runner": "local", @@ -8578,11 +8767,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job." + "evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. Codex used the legacy platform delay before this candidate; the late-marker oracle proves the original deadline would pre-empt slow-host settlement without re-arming. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job." }, "performanceBudget": { "required": true, - "evidence": "Agent prompt dispatch remains O(prompt bytes) with existing 16KB chunking. Claude adds one PTY-local output listener until 1.5s of post-marker output quiescence or an 8s fallback; each later frame resets one timer. Other agents retain the platform delay. No polling, provider listing, subprocess churn, hidden-pane wakeups, renderer work, or wire traffic is added." + "evidence": "Agent prompt dispatch remains O(prompt bytes) with existing 16KB chunking. Claude and Codex add one PTY-local output listener until 1.5s of post-marker output quiescence or an 8s fallback; a late first marker restarts the bounded window and each later frame resets one quiet timer. Other agents retain the platform delay. No polling, provider listing, subprocess churn, hidden-pane wakeups, renderer work, or wire traffic is added." }, "promotionCriteria": [ "Run the live harness in soak with a self-starting dev runtime or provider-contract fixture.", @@ -12056,6 +12245,105 @@ "Platform-named bundles are loaded on macOS locally; physical Linux and Windows evidence is not yet recorded." ], "demotionRule": "Demote if any shipped companion stops loading on Node 18, selected installers mutate outside the isolated home, or the CI lane no longer runs the built artifact with Node 18." + }, + { + "id": "terminal-history.daemon-checkpoint-session-isolation", + "title": "Daemon history checkpoints isolate stalled terminal sessions", + "maturity": "experimental", + "protection": "partial", + "owner": "daemon-terminal", + "layer": "main-process-daemon-history", + "surfaces": [ + "warm daemon terminal reattach", + "deep daemon buffer snapshots", + "periodic and final durable-history checkpoints" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local-daemon", "ssh-daemon", "wsl-daemon", "paired-runtime-host"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local-daemon"], + "coverageNotes": "A real daemon server and adapter on macOS cover per-session isolation, same-session deadlines, post-deadline commit, aggregate overlay admission, and live-window fallback. The provider-neutral adapter has no new path or wire assumptions; a live Linux SSH-relay journey covers the unchanged remote consumer, while live Linux daemon, Windows, WSL, and paired-runtime-host journeys remain gaps.", + "motivatingLinks": [ + "https://linear.app/stably/issue/STA-4173", + "https://github.com/stablyai/orca/pull/14346" + ], + "invariant": "A stalled durable-history checkpoint may delay only its own daemon session. Warm reattach and deep snapshots degrade to the live window after five seconds, non-final background checkpoints defer after fifteen seconds, same-session writes remain serialized, non-final running plus queued work stays bounded to three per session, overlay compacts stay bounded to four across sessions, and final sleep or disconnect checkpoints remain deadline-free.", + "oracle": "Park one session inside the real history checkpoint write, then require an unrelated warm reattach to return within two seconds with its own output while the first write remains parked. Require the stalled session to return its live window after the overlay deadline and later commit the abandoned write. Park four distinct overlay compacts, require a fifth reattach to return its live window without starting another compact, and require fast non-final RPC rejection plus both deadlines to leave diagnostic breadcrumbs.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-checkpoint-session-queue.test.ts src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts src/main/daemon/daemon-pty-adapter.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/daemon/daemon-checkpoint-session-queue.test.ts", + "src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts", + "src/main/daemon/daemon-pty-adapter.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/daemon-checkpoint-session-queue.test.ts", + "assertions": [ + "same-session operations serialize while unrelated sessions run independently", + "deadline fallback leaves the operation running and retains later waiter ordering", + "operation rejection remains distinct from a deadline", + "running plus queued work is bounded per session and drains" + ] + }, + { + "file": "src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts", + "assertions": [ + "an unrelated session reattaches while another checkpoint remains parked", + "same-session deadline fallback returns the live window and logs a breadcrumb", + "an abandoned checkpoint commits when the stalled write resumes", + "four parked overlays turn away a fifth compact without blocking its reattach" + ] + }, + { + "file": "src/main/daemon/daemon-pty-adapter.test.ts", + "assertions": [ + "periodic checkpoint work caps concurrent snapshot, disk operations, and admission probes at four", + "non-final checkpoint RPC failures retain the existing warning path", + "final checkpoints bypass cooldown and remain awaited" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-13", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-checkpoint-session-queue.test.ts src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts src/main/daemon/daemon-pty-adapter.test.ts --reporter=dot", + "durationSeconds": 15, + "summary": "Three focused files passed, including unrelated-session reattach, five-second fallback, post-deadline durable commit, aggregate non-final admission, late-rejection diagnostics, and final-checkpoint behavior." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "focused queue, real daemon-adapter isolation, and daemon adapter regression suite" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Fresh deterministic local runs pass; CI and soak history have not started." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Origin main routes the parked overlay through one process-wide checkpoint tail, while the candidate oracle observes unrelated reattach at about 79 milliseconds. The exact reverted-source oracle was not executed in this review worktree." + }, + "performanceBudget": { + "required": true, + "evidence": "Non-final work holds one admission per session and four globally; a full admission set stops the current periodic pass after four probes, and its diagnostic state stays bounded to the active admissions plus one global marker. Deadline timers are unrefed, abandoned waits retain admission until the actual write settles, dirty-gated polling remains unchanged, final checkpoints remain deadline-free, and the change adds no subprocess, renderer, store, startup probe, or wire work." + }, + "promotionCriteria": [ + "Execute the oracle against an affected baseline and retain the exact red result.", + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Collect live Linux daemon, Windows, WSL, and paired-runtime-host reattach evidence.", + "Keep per-session and aggregate admission, deadline, diagnostics, and final-checkpoint assertions green." + ], + "knownGaps": [ + "Live Electron evidence covers the unchanged Linux SSH-relay consumer rather than a daemon running on the SSH host.", + "Physical Windows, WSL, Linux daemon, and paired-runtime-host journeys were not run.", + "The reverted-source failure is established from the old process-wide call path but was not executed." + ], + "demotionRule": "Demote if unrelated reattach joins a stalled session tail, overlay or periodic work exceeds its admission bound, a deadline cancels durable work, final checkpoints defer, or failures and deadline fallbacks lose their diagnostic breadcrumbs." } ] } diff --git a/config/scripts/locale-ko-value-overrides.mjs b/config/scripts/locale-ko-value-overrides.mjs index 5dac8b29a0d..bcc5b6e9e8e 100644 --- a/config/scripts/locale-ko-value-overrides.mjs +++ b/config/scripts/locale-ko-value-overrides.mjs @@ -173,7 +173,7 @@ export const KO_VALUE_OVERRIDES = { '아래 워크스페이스 오류를 확인한 후 새로고침하세요.', 'Review inactive workspaces before deleting their local files and Orca state.': '로컬 파일과 Orca 상태를 삭제하기 전에 비활성 워크스페이스를 확인하세요.', - 'Review inactive workspaces (': '비활성 워크스페이스 확인(', + 'Clean up workspaces': '워크스페이스 정리', 'Review the settings that will be imported from your Ghostty config.': 'Ghostty 구성에서 가져올 설정을 확인하세요.', 'GitHub requires review approval before this pull request can merge': diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 610bdb8b92e..7a9c2cb494e 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 19m + + downloads: 20m @@ -15,7 +15,7 @@ downloads downloads - 19m - 19m + 20m + 20m diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index 2b0b6cdf560..432ac2d4d8c 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -7,12 +7,12 @@ import { colors } from '../theme/mobile-theme' import { useHostClient } from '../transport/client-context' import type { RpcSuccess } from '../transport/types' import type { RpcClient } from '../transport/rpc-client' +import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform' import { getWorktreeLabel } from '../session/worktree-label' import { activateStructuredAiVaultSession, buildMobileAiVaultResumeLaunch, createMobileAiVaultResumeMutationRegistry, - readMobileRuntimeHostPlatform, readMobileRuntimeTerminalWindowsShell, resolveMobileAiVaultResumePlatform, resumeAiVaultSessionInTerminal, diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index 4d8e95bec89..4d954a2464f 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -44,18 +44,32 @@ vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' })) vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' })) import { setCachedRepos } from '../cache/repo-cache' +import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host' import { NewWorktreeModal } from './NewWorktreeModal' -const repos = [{ id: 'repo-1', displayName: 'orca', path: '/src/orca', kind: 'git' }] +const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin') -function repoPickerNames(renderer: ReactTestRenderer | null): string[] { - const pickers = renderer?.root.findAll((node) => node.type === 'PickerListDrawer') ?? [] - const repoPicker = pickers.find((node) => node.props.title === 'Repository') - return ((repoPicker?.props.items ?? []) as { label: string }[]).map((item) => item.label) +const repos = [ + { + id: 'repo-1', + displayName: 'orca', + path: '/src/orca', + kind: 'git', + upstream: { owner: 'stablyai', repo: 'orca' } + } +] + +function pickerItems( + renderer: ReactTestRenderer, + title: string +): { label: string; detail: string }[] { + const pickers = renderer.root.findAll((node) => node.type === 'PickerListDrawer') + const picker = pickers.find((node) => node.props.title === title) + return picker?.props.items ?? [] } -describe('NewWorktreeModal repo list', () => { - let renderer: ReactTestRenderer | null = null +describe('NewWorktreeModal project targets', () => { + let renderer: ReactTestRenderer beforeEach(() => { setCachedRepos('host-1', repos) @@ -63,7 +77,6 @@ describe('NewWorktreeModal repo list', () => { afterEach(() => { act(() => renderer?.unmount()) - renderer = null }) it('keeps the cached repos when the in-flight repo.list rejects on a dropped connection', async () => { @@ -71,6 +84,9 @@ describe('NewWorktreeModal repo list', () => { if (method === 'repo.list') { return Promise.reject(new Error('connection closed')) } + if (method === 'status.get') { + return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } }) + } return new Promise(() => {}) }) const client = { sendRequest } as unknown as RpcClient @@ -92,6 +108,60 @@ describe('NewWorktreeModal repo list', () => { }) expect(sendRequest).toHaveBeenCalledWith('repo.list') - expect(repoPickerNames(renderer)).toEqual(['orca']) + expect(pickerItems(renderer, 'Project')).toEqual([ + expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' }) + ]) + expect(pickerItems(renderer, 'Run on')).toEqual([ + expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }) + ]) + }) + + it('groups same-name checkouts under one project with separate run targets', async () => { + const listedRepos = [ + ...repos, + { + id: 'repo-2', + displayName: 'orca', + path: '/home/dev/orca', + connectionId: 'build-server', + kind: 'git', + upstream: { owner: 'stablyai', repo: 'orca' } + } + ] + const client = { + sendRequest: vi.fn().mockImplementation((method: string) => { + if (method === 'repo.list') { + return Promise.resolve({ ok: true, result: { repos: listedRepos } }) + } + if (method === 'status.get') { + return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } }) + } + return new Promise(() => {}) + }) + } as unknown as RpcClient + + await act(async () => { + renderer = create( + createElement(NewWorktreeModal, { + visible: true, + client, + hostId: 'host-1', + onCreated: () => {}, + onClose: () => {} + }) + ) + await Promise.resolve() + }) + + expect(pickerItems(renderer, 'Project')).toEqual([ + expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' }) + ]) + expect(pickerItems(renderer, 'Run on')).toEqual([ + expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }), + expect.objectContaining({ + label: 'SSH · build-server', + detail: '/home/dev/orca' + }) + ]) }) }) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 8e484141fbf..1941c75a1b4 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -10,7 +10,7 @@ import { ActivityIndicator, Keyboard } from 'react-native' -import { ChevronDown, ChevronUp } from 'lucide-react-native' +import { ChevronDown, ChevronUp, Monitor } from 'lucide-react-native' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' import { colors, spacing, radii, typography } from '../theme/mobile-theme' @@ -30,8 +30,10 @@ import { } from '../tasks/setup-hook-trust' import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents' import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types' +import type { Repo as SharedRepo } from '../../../src/shared/repo-types' import type { TuiAgent } from '../../../src/shared/tui-agent' import type { SshConnectionState } from '../../../src/shared/ssh-types' +import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection' import { NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS, NEW_WORKTREE_BLANK_AGENT as BLANK_TERMINAL, @@ -64,17 +66,26 @@ import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField' import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer' import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields' import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer' +import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields' +import { + buildNewWorkspaceProjectOptions, + buildNewWorkspaceRunTargetOptions, + getNewWorkspaceRunTarget +} from './new-workspace-project-targets' -type Repo = { - id: string - displayName: string - path: string - badgeColor?: string - connectionId?: string | null - kind?: 'git' | 'folder' - upstream?: { owner: string; repo: string } | null - gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null -} +type Repo = Pick & + Partial< + Pick< + SharedRepo, + | 'badgeColor' + | 'connectionId' + | 'executionHostId' + | 'kind' + | 'upstream' + | 'repoIcon' + | 'gitRemoteIdentity' + > + > type SetupDecision = 'inherit' | 'run' | 'skip' type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' @@ -201,10 +212,8 @@ function NewWorktreeModalContent({ const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null) const [note, setNote] = useState('') const [availableProviders, setAvailableProviders] = useState([]) - const { tasksSupported, getWorktreeCreateCutoverSupport } = useNewWorktreeRuntimeCapabilities( - client, - visible - ) + const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } = + useNewWorktreeRuntimeCapabilities(client, visible) const [showAdvanced, setShowAdvanced] = useState(false) const [setupHookDetails, setSetupHookDetails] = useState(null) const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) @@ -695,10 +704,17 @@ function NewWorktreeModalContent({ isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents) ) const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL] - const repoPickerItems = useMemo( - () => repos.map((repo) => ({ id: repo.id, label: repo.displayName, repo })), - [repos] + const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos]) + const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null + const selectedProject = + projectPickerItems.find((project) => project.id === selectedProjectId) ?? null + const runTargetPickerItems = useMemo( + () => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform), + [hostPlatform, repos, selectedProjectId] ) + const selectedRunTarget = selectedRepo + ? getNewWorkspaceRunTarget(selectedRepo, hostPlatform) + : null function prepareSelectionPickerOpen(): void { // Why: picker taps can beat an open soft keyboard; dismissing it prevents the @@ -786,10 +802,7 @@ function NewWorktreeModalContent({ > - Create Workspace - - Pick a repository and agent to spin up a new workspace. - + Create worktree {loading ? ( @@ -798,33 +811,23 @@ function NewWorktreeModalContent({ ) : repos.length === 0 ? ( - No repositories found + No projects found ) : ( <> - - Repository - { - prepareSelectionPickerOpen() - transitionDrawer('repo') - }} - > - {selectedRepo ? ( - - ) : null} - - {selectedRepo?.displayName ?? 'Select a repository'} - - - - + { + prepareSelectionPickerOpen() + transitionDrawer('project') + }} + onOpenRunTarget={() => { + prepareSelectionPickerOpen() + transitionDrawer('runTarget') + }} + /> - {sshGate.requiresConnection ? 'Connect repository first' : selectedAgent.label} + {sshGate.requiresConnection ? 'Connect target first' : selectedAgent.label} @@ -996,7 +999,7 @@ function NewWorktreeModalContent({ ) : ( - {sshGate.requiresConnection ? 'Connect Repository' : 'Create Workspace'} + {sshGate.requiresConnection ? 'Connect target' : 'Create worktree'} )} @@ -1025,10 +1028,10 @@ function NewWorktreeModalContent({ /> handleRepoSelected(item.repo)} onClose={() => transitionDrawer('form')} renderIcon={(item) => { @@ -1036,6 +1039,16 @@ function NewWorktreeModalContent({ }} /> + handleRepoSelected(item.repo)} + onClose={() => transitionDrawer('form')} + renderIcon={() => } + /> + void + onOpenRunTarget: () => void +}) { + return ( + <> + + Project + + {projectBadgeColor ? ( + + ) : null} + + + + + + + Run on + + + + + + + + ) +} + +function SelectionCopy({ + selection, + placeholder +}: { + selection: Selection | null + placeholder: string +}) { + return ( + + + {selection?.label ?? placeholder} + + {selection?.detail ? ( + + {selection.detail} + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + fieldButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm + }, + projectDot: { + width: 8, + height: 8, + borderRadius: 999 + }, + fieldButtonCopy: { + flex: 1, + minWidth: 0 + }, + fieldButtonText: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + fieldButtonDetail: { + fontSize: typography.metaSize, + color: colors.textMuted, + marginTop: 1 + }, + fieldButtonPlaceholder: { + color: colors.textMuted + } +}) diff --git a/mobile/src/components/PickerListDrawer.tsx b/mobile/src/components/PickerListDrawer.tsx index e908c98f449..a75d77d789a 100644 --- a/mobile/src/components/PickerListDrawer.tsx +++ b/mobile/src/components/PickerListDrawer.tsx @@ -6,7 +6,9 @@ import { colors, spacing, typography } from '../theme/mobile-theme' import { BottomDrawer } from './BottomDrawer' import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants' -type Props = { +type PickerListItem = { id: string; label: string; detail?: string } + +type Props = { visible: boolean title: string items: T[] @@ -16,7 +18,7 @@ type Props = { renderIcon?: (item: T) => ReactNode } -export function PickerListDrawer({ +export function PickerListDrawer({ visible, title, items, @@ -87,12 +89,19 @@ export function PickerListDrawer({ onPress={() => closeThenSelect(item)} > {renderIcon?.(item)} - - {item.label} - + + + {item.label} + + {item.detail ? ( + + {item.detail} + + ) : null} + {selected && } ) @@ -142,10 +151,18 @@ const styles = StyleSheet.create({ backgroundColor: colors.bgRaised }, itemText: { - flex: 1, fontSize: typography.bodySize, color: colors.textPrimary }, + itemCopy: { + flex: 1, + minWidth: 0 + }, + itemDetail: { + fontSize: typography.metaSize, + color: colors.textMuted, + marginTop: 1 + }, itemTextSelected: { fontWeight: '600' } diff --git a/mobile/src/components/new-workspace-project-targets.test.ts b/mobile/src/components/new-workspace-project-targets.test.ts new file mode 100644 index 00000000000..d77f89db412 --- /dev/null +++ b/mobile/src/components/new-workspace-project-targets.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host' +import { + buildNewWorkspaceProjectOptions, + buildNewWorkspaceRunTargetOptions, + getNewWorkspaceRunTarget +} from './new-workspace-project-targets' + +const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin') + +describe('new workspace project targets', () => { + it('groups local and SSH checkouts of the same project', () => { + const upstream = { owner: 'stablyai', repo: 'orca' } + const options = buildNewWorkspaceProjectOptions([ + { id: 'local', displayName: 'orca', path: '/src/orca', upstream }, + { + id: 'ssh', + displayName: 'orca', + path: '/home/dev/orca', + connectionId: 'build-server', + upstream + } + ]) + + expect(options).toHaveLength(1) + expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' }) + }) + + it('shows the provider slug recovered from canonical git identity', () => { + const options = buildNewWorkspaceProjectOptions([ + { + id: 'local', + displayName: 'orca', + path: '/src/orca', + gitRemoteIdentity: { + canonicalKey: 'github.com/stablyai/orca', + remoteName: 'origin', + remoteUrl: 'git@github.com:stablyai/orca.git' + } + } + ]) + + expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' }) + }) + + it('labels local, SSH, and paired runtime targets', () => { + expect( + getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: '/src/orca' }, 'darwin') + ).toEqual({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }) + expect( + getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' }) + ).toEqual({ label: 'This computer', detail: 'C:\\src\\orca' }) + expect( + getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' }, 'win32') + ).toEqual({ label: 'Local Windows', detail: 'C:\\src\\orca' }) + expect( + getNewWorkspaceRunTarget({ + id: 'ssh', + displayName: 'orca', + path: 'C:\\src\\orca', + executionHostId: 'ssh:Windows%20VM' + }) + ).toEqual({ label: 'SSH · Windows VM', detail: 'C:\\src\\orca' }) + expect( + getNewWorkspaceRunTarget({ + id: 'runtime', + displayName: 'orca', + path: '/src/orca', + executionHostId: 'runtime:devbox' + }) + ).toEqual({ label: 'Remote · devbox', detail: '/src/orca' }) + }) + + it('shows one target per host when the project has multiple local worktrees', () => { + const upstream = { owner: 'stablyai', repo: 'orca' } + const repos = [ + { id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream }, + { id: 'local-b', displayName: 'orca-b', path: '/src/orca-b', upstream }, + { + id: 'ssh', + displayName: 'orca', + path: '/home/dev/orca', + connectionId: 'build-server', + upstream + } + ] + const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null + + expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([ + expect.objectContaining({ id: 'local-a', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }), + expect.objectContaining({ id: 'ssh', label: 'SSH · build-server', detail: '/home/dev/orca' }) + ]) + }) +}) diff --git a/mobile/src/components/new-workspace-project-targets.ts b/mobile/src/components/new-workspace-project-targets.ts new file mode 100644 index 00000000000..5e45c35a3e9 --- /dev/null +++ b/mobile/src/components/new-workspace-project-targets.ts @@ -0,0 +1,106 @@ +import type { Repo } from '../../../src/shared/repo-types' +import { + getExecutionHostLabel, + getLocalExecutionHostLabel, + getRepoExecutionHostId, + parseExecutionHostId +} from '../../../src/shared/execution-host' +import { + getProjectIdentityKey, + getProjectProviderIdentity +} from '../../../src/shared/project-host-setup-projection' + +type WorkspaceRepo = Pick & + Partial< + Pick + > + +export type NewWorkspaceProjectOption = { + id: string + label: string + detail?: string + repo: TRepo +} + +export type NewWorkspaceRunTargetOption = { + id: string + label: string + detail: string + repo: TRepo +} + +export function buildNewWorkspaceProjectOptions( + repos: readonly TRepo[] +): NewWorkspaceProjectOption[] { + const options = new Map>() + const hostIdsByProject = new Map>() + for (const repo of repos) { + const id = getProjectIdentityKey(repo) + const hostIds = hostIdsByProject.get(id) ?? new Set() + hostIds.add(getRepoExecutionHostId(repo)) + hostIdsByProject.set(id, hostIds) + if (!options.has(id)) { + options.set(id, { + id, + label: repo.displayName, + repo + }) + } + } + return [...options.values()].map((option) => { + const providerIdentity = getProjectProviderIdentity(option.repo) + const providerDetail = providerIdentity + ? `${providerIdentity.owner}/${providerIdentity.repo}` + : '' + const hostCount = hostIdsByProject.get(option.id)?.size ?? 0 + const detail = providerDetail || (hostCount > 1 ? `${hostCount} hosts configured` : '') + return detail ? { ...option, detail } : option + }) +} + +export function getNewWorkspaceRunTarget( + repo: WorkspaceRepo, + localPlatform: NodeJS.Platform | null = null +): { + label: string + detail: string +} { + const hostId = getRepoExecutionHostId(repo) + const host = parseExecutionHostId(hostId) + const hostLabel = getExecutionHostLabel(hostId) + if (host?.kind === 'ssh') { + return { label: `SSH · ${hostLabel}`, detail: repo.path } + } + if (host?.kind === 'runtime') { + return { label: `Remote · ${hostLabel}`, detail: repo.path } + } + return { + label: localPlatform ? getLocalExecutionHostLabel(localPlatform) : 'This computer', + detail: repo.path + } +} + +export function buildNewWorkspaceRunTargetOptions( + repos: readonly TRepo[], + projectId: string | null, + localPlatform: NodeJS.Platform | null = null +): NewWorkspaceRunTargetOption[] { + if (!projectId) { + return [] + } + const options = new Map>() + for (const repo of repos) { + if (getProjectIdentityKey(repo) !== projectId) { + continue + } + const hostId = getRepoExecutionHostId(repo) + if (!options.has(hostId)) { + options.set(hostId, { + id: repo.id, + ...getNewWorkspaceRunTarget(repo, localPlatform), + repo + }) + } + } + return [...options.values()] +} diff --git a/mobile/src/components/new-worktree-form-sheet-visibility.test.ts b/mobile/src/components/new-worktree-form-sheet-visibility.test.ts index 450097b74b0..eebe66015fe 100644 --- a/mobile/src/components/new-worktree-form-sheet-visibility.test.ts +++ b/mobile/src/components/new-worktree-form-sheet-visibility.test.ts @@ -30,7 +30,7 @@ describe('resolveNewWorktreeFormSheetVisible', () => { expect( resolveNewWorktreeFormSheetVisible({ modalVisible: true, - drawerView: 'repo', + drawerView: 'project', formPinnedUnderSource: false }) ).toBe(false) diff --git a/mobile/src/components/use-new-worktree-drawer-navigation.ts b/mobile/src/components/use-new-worktree-drawer-navigation.ts index c21a6dee4f1..4f3261cb685 100644 --- a/mobile/src/components/use-new-worktree-drawer-navigation.ts +++ b/mobile/src/components/use-new-worktree-drawer-navigation.ts @@ -2,7 +2,14 @@ import { useEffect, useRef, useState } from 'react' import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants' import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility' -export type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust' +export type NewWorktreeDrawerView = + | 'form' + | 'transition' + | 'source' + | 'project' + | 'runTarget' + | 'agent' + | 'trust' // Why: iOS cannot reliably present a second native modal until the first drawer's // exit commits; one extra frame keeps transitions sequential on slower devices. diff --git a/mobile/src/files/mobile-file-tab-doc.test.ts b/mobile/src/files/mobile-file-tab-doc.test.ts index 06ee3510c05..6bd153c67bb 100644 --- a/mobile/src/files/mobile-file-tab-doc.test.ts +++ b/mobile/src/files/mobile-file-tab-doc.test.ts @@ -138,6 +138,17 @@ describe('resolveMobileFileTabDoc', () => { }) }) + // Why: the host now caps oversized diffs with an error envelope, and a client that knows nothing + // about the code must still surface the message instead of rendering an empty diff. + it('propagates a host diff_too_large failure instead of rendering an empty diff', async () => { + const client = clientOf({ + 'git.diff': fail('diff_too_large', 'This diff is too large to open over a remote connection.') + }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'a.ts', diffSource: 'staged' }) + ).rejects.toThrow('This diff is too large to open over a remote connection.') + }) + it('propagates the RPC error message when a read fails', async () => { const client = clientOf({ 'files.read': fail('EIO', 'file_too_large') }) await expect( diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index 274c75f0778..0bf50a668bd 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -6,11 +6,11 @@ import { buildMobileAiVaultResumeLaunch, buildMobileAiVaultResumeCommand, createMobileAiVaultResumeMutationRegistry, - readMobileRuntimeHostPlatform, readMobileRuntimeTerminalWindowsShell, resolveMobileAiVaultResumePlatform, resumeAiVaultSessionInTerminal } from './ai-vault-resume-launch' +import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform' import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation' describe('activateStructuredAiVaultSession', () => { diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index 2da8148efb2..7cebddd2215 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -24,20 +24,6 @@ import { } from './mobile-diff-review-rpc' import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target' -const NODE_PLATFORMS = new Set([ - 'aix', - 'android', - 'darwin', - 'freebsd', - 'haiku', - 'linux', - 'openbsd', - 'sunos', - 'win32', - 'cygwin', - 'netbsd' -]) - export function buildMobileAiVaultResumeCommand(args: { session: Pick & Partial> @@ -260,16 +246,6 @@ export function createMobileAiVaultResumeMutationRegistry( } } -export function readMobileRuntimeHostPlatform(statusResult: unknown): NodeJS.Platform | null { - if (!statusResult || typeof statusResult !== 'object') { - return null - } - const hostPlatform = (statusResult as { hostPlatform?: unknown }).hostPlatform - return typeof hostPlatform === 'string' && NODE_PLATFORMS.has(hostPlatform as NodeJS.Platform) - ? (hostPlatform as NodeJS.Platform) - : null -} - export function readMobileRuntimeTerminalWindowsShell(statusResult: unknown): string | null { if (!statusResult || typeof statusResult !== 'object') { return null diff --git a/mobile/src/session/mobile-diff-review-loaders.test.ts b/mobile/src/session/mobile-diff-review-loaders.test.ts new file mode 100644 index 00000000000..a2b350bb395 --- /dev/null +++ b/mobile/src/session/mobile-diff-review-loaders.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue' +import { loadMobileDiffReviewDiff } from './mobile-diff-review-loaders' + +const DELETED_ITEM: MobileDiffReviewQueueItem = { + key: 'unstaged\0unstaged\0\0deleted.ts', + scope: 'unstaged', + area: 'unstaged', + filePath: 'deleted.ts', + status: 'deleted', + title: 'deleted.ts', + subtitle: 'Unstaged', + canStage: true, + canUnstage: false, + canDiscard: true, + isGeneratedOrLockFile: false, + diffIdentity: 'deleted-diff', + noteCount: 0, + unsentNoteCount: 0, + staleNoteCount: 0, + isReviewed: false, + changedSinceReview: false +} + +function clientWith(response: RpcResponse): RpcClient { + return { + sendRequest: vi.fn().mockResolvedValue(response) + } as unknown as RpcClient +} + +function failure(code: string, message: string): RpcResponse { + return { id: 'rpc-1', ok: false, error: { code, message }, _meta: { runtimeId: 'runtime-1' } } +} + +describe('loadMobileDiffReviewDiff', () => { + it('shows the too-large state for an oversized deleted diff', async () => { + await expect( + loadMobileDiffReviewDiff({ + client: clientWith(failure('diff_too_large', 'Diff too large')), + worktreeId: 'wt-1', + item: DELETED_ITEM, + branchCompare: null + }) + ).resolves.toEqual({ kind: 'too-large', itemKey: DELETED_ITEM.key }) + }) + + it('keeps the deleted fallback for hosts that cannot return deleted content', async () => { + await expect( + loadMobileDiffReviewDiff({ + client: clientWith(failure('internal_error', 'Unable to read deleted file')), + worktreeId: 'wt-1', + item: DELETED_ITEM, + branchCompare: null + }) + ).resolves.toEqual({ kind: 'deleted', itemKey: DELETED_ITEM.key }) + }) +}) diff --git a/mobile/src/session/mobile-diff-review-loaders.ts b/mobile/src/session/mobile-diff-review-loaders.ts index dfdaed2cd7c..0b1a14d156b 100644 --- a/mobile/src/session/mobile-diff-review-loaders.ts +++ b/mobile/src/session/mobile-diff-review-loaders.ts @@ -130,6 +130,9 @@ export async function loadMobileDiffReviewDiff(input: DiffLoadInput): Promise): RpcClien return { id: '1', ok: true, - result: { capabilities: outcome }, + result: { capabilities: outcome, hostPlatform: 'darwin' }, _meta: { runtimeId: 'r' } } } @@ -33,7 +33,8 @@ describe('readNewWorktreeRuntimeCapabilities', () => { ) ).resolves.toEqual({ tasksSupported: true, - idempotentWorktreeCreateSupported: true + idempotentWorktreeCreateSupported: true, + hostPlatform: 'darwin' }) }) @@ -44,14 +45,16 @@ describe('readNewWorktreeRuntimeCapabilities', () => { ) ).resolves.toEqual({ tasksSupported: false, - idempotentWorktreeCreateSupported: true + idempotentWorktreeCreateSupported: true, + hostPlatform: 'darwin' }) }) it('fails closed when capability detection is unavailable', async () => { await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({ tasksSupported: false, - idempotentWorktreeCreateSupported: false + idempotentWorktreeCreateSupported: false, + hostPlatform: null }) }) }) diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index d9b9f75c0d2..6083120c7a4 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { RpcClient } from '../transport/rpc-client' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import type { RpcSuccess } from '../transport/types' +import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform' import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability' // Why: older hosts strip worktree.create's clientMutationId, so mobile must not @@ -14,11 +15,13 @@ const STATUS_CUTOVER_MAX_RETRIES = 5 export type NewWorktreeRuntimeCapabilities = { tasksSupported: boolean idempotentWorktreeCreateSupported: boolean + hostPlatform: NodeJS.Platform | null } const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = { tasksSupported: false, - idempotentWorktreeCreateSupported: false + idempotentWorktreeCreateSupported: false, + hostPlatform: null } // Why: status.get is safe to replay and must settle before create, independently @@ -32,13 +35,14 @@ export async function readNewWorktreeRuntimeCapabilities( if (!response.ok) { return UNSUPPORTED_CAPABILITIES } - const capabilities = - ((response as RpcSuccess).result as { capabilities?: string[] }).capabilities ?? [] + const result = (response as RpcSuccess).result as { capabilities?: string[] } + const capabilities = result.capabilities ?? [] return { tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY), idempotentWorktreeCreateSupported: capabilities.includes( MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY - ) + ), + hostPlatform: readMobileRuntimeHostPlatform(result) } } catch (error) { if (!isLogicalClientCutoverError(error) || migrationRetry >= STATUS_CUTOVER_MAX_RETRIES) { @@ -53,9 +57,11 @@ export function useNewWorktreeRuntimeCapabilities( enabled: boolean ): { tasksSupported: boolean + hostPlatform: NodeJS.Platform | null getWorktreeCreateCutoverSupport: () => Promise } { const [tasksSupported, setTasksSupported] = useState(false) + const [hostPlatform, setHostPlatform] = useState(null) const capabilityProbeRef = useRef<{ client: RpcClient | null promise: Promise @@ -82,6 +88,7 @@ export function useNewWorktreeRuntimeCapabilities( void getCapabilities().then((capabilities) => { if (!stale) { setTasksSupported(capabilities.tasksSupported) + setHostPlatform(capabilities.hostPlatform) } }) return () => { @@ -93,5 +100,5 @@ export function useNewWorktreeRuntimeCapabilities( () => getCapabilities().then((capabilities) => capabilities.idempotentWorktreeCreateSupported), [getCapabilities] ) - return { tasksSupported, getWorktreeCreateCutoverSupport } + return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } } diff --git a/mobile/src/transport/mobile-rpc-request-projection.ts b/mobile/src/transport/mobile-rpc-request-projection.ts new file mode 100644 index 00000000000..ce4014da697 --- /dev/null +++ b/mobile/src/transport/mobile-rpc-request-projection.ts @@ -0,0 +1,12 @@ +const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_PARAM = 'supportsWorktreeVisibilitySourceDefaults' + +export function projectMobileRpcRequestParams(method: string, params: unknown): unknown { + if (method !== 'worktree.ps') { + return params + } + const current = + params && typeof params === 'object' && !Array.isArray(params) + ? (params as Record) + : {} + return { ...current, [WORKTREE_VISIBILITY_SOURCE_DEFAULTS_PARAM]: true } +} diff --git a/mobile/src/transport/mobile-runtime-host-platform.ts b/mobile/src/transport/mobile-runtime-host-platform.ts new file mode 100644 index 00000000000..cf7b13aa283 --- /dev/null +++ b/mobile/src/transport/mobile-runtime-host-platform.ts @@ -0,0 +1,20 @@ +const NODE_PLATFORMS = new Set([ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd' +]) + +export function readMobileRuntimeHostPlatform(statusResult: unknown): NodeJS.Platform | null { + const hostPlatform = (statusResult as { hostPlatform?: unknown } | null)?.hostPlatform + return typeof hostPlatform === 'string' && NODE_PLATFORMS.has(hostPlatform as NodeJS.Platform) + ? (hostPlatform as NodeJS.Platform) + : null +} diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index ffbd6c1c476..9cd6598f4ee 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -79,6 +79,23 @@ describe('stable logical RPC client', () => { expect(session.confirmStructuredStreamLongevity).toHaveBeenCalledOnce() }) + it('advertises source-default support on worktree catalog requests', async () => { + const session = new FakeSession('connected') + session.sendRequest.mockResolvedValue(success([])) + const client = createStableLogicalRpcClient(session, 'lan') + + await client.sendRequest('worktree.ps', { limit: 10_000 }) + await client.sendRequest('status.get') + + expect(session.sendRequest).toHaveBeenNthCalledWith( + 1, + 'worktree.ps', + { limit: 10_000, supportsWorktreeVisibilitySourceDefaults: true }, + undefined + ) + expect(session.sendRequest).toHaveBeenNthCalledWith(2, 'status.get', undefined, undefined) + }) + it('makes before break, rejects in-flight work, and replays subscriptions', async () => { const oldSession = new FakeSession('connected') const nextSession = new FakeSession('connecting') diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index 38677811959..a456a08a565 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -5,6 +5,7 @@ import { type MigrationDialStateForwarder } from './migration-dial-state-forwarder' import { waitForAuthenticated } from './replacement-session-authentication' +import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection' export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay' @@ -84,22 +85,24 @@ export function createStableLogicalRpcClient( return new Promise((resolve, reject) => { const pending = { reject } pendingRequests.add(pending) - void session.sendRequest(method, params, options).then( - (response) => { - pendingRequests.delete(pending) - if (closed) { - reject(new Error('Client closed')) - } else if (requestGeneration !== generation) { - reject(new LogicalClientCutoverError()) - } else { - resolve(response) + void session + .sendRequest(method, projectMobileRpcRequestParams(method, params), options) + .then( + (response) => { + pendingRequests.delete(pending) + if (closed) { + reject(new Error('Client closed')) + } else if (requestGeneration !== generation) { + reject(new LogicalClientCutoverError()) + } else { + resolve(response) + } + }, + (error: unknown) => { + pendingRequests.delete(pending) + reject(error) } - }, - (error: unknown) => { - pendingRequests.delete(pending) - reject(error) - } - ) + ) }) }, diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 7cba4d1cdc1..682f82ac187 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -20,7 +20,9 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION, - SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' vi.mock('./launch', () => ({ @@ -64,7 +66,9 @@ describe('CLI remote WebSocket transport', () => { expect.objectContaining({ clientCapabilities: [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, - AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY ] }) ) diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts index b4518220e90..7c321be5515 100644 --- a/src/main/agent-hooks/installer-utils-remote.ts +++ b/src/main/agent-hooks/installer-utils-remote.ts @@ -48,11 +48,13 @@ export async function readHooksJsonRemote( export async function writeHooksJsonRemote( sftp: SFTPWrapper, remotePath: string, - config: HooksConfig + config: HooksConfig, + // Why: mirrors the local writer — a JSONC config supplies text edited in place. + options?: { serialized?: string } ): Promise { const dir = dirnamePosix(remotePath) await mkdirpRemote(sftp, dir) - const serialized = `${JSON.stringify(config, null, 2)}\n` + const serialized = options?.serialized ?? `${JSON.stringify(config, null, 2)}\n` // Why: skip the write when on-disk content is identical so repeated // install() calls do not bump the file's mtime / inode unnecessarily. try { diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index f6e4c131826..8861d41f0cc 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -316,7 +316,9 @@ function writeScriptWithAclRetry(scriptPath: string, content: string): void { export function writeHooksJson( configPath: string, config: HooksConfig, - options?: { preserveMode?: boolean } + // Why: `serialized` lets a JSONC config (Devin) supply text edited in place, so the + // atomic write + rolling backup below stay shared instead of being reimplemented. + options?: { preserveMode?: boolean; serialized?: string } ): void { const writePath = resolveHooksJsonWritePath(configPath) const dir = dirname(writePath) @@ -325,7 +327,7 @@ export function writeHooksJson( // Why: temp+rename leaves the original untouched on a crash/disk-full mid-write. // Why randomUUID: avoids tmp-path collisions when two install() calls fire in the same millisecond. const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) - const serialized = `${JSON.stringify(config, null, 2)}\n` + const serialized = options?.serialized ?? `${JSON.stringify(config, null, 2)}\n` const existingMode = options?.preserveMode === true && existsSync(writePath) ? statSync(writePath).mode : undefined diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 9ba01bc81c6..1627821d5b1 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { parse as parseJsonc } from 'jsonc-parser' import type { SFTPWrapper } from 'ssh2' vi.mock('electron', () => ({ @@ -415,7 +416,11 @@ describe('remote hook service installers', () => { expect(grokConfig.hooks.PostToolUse?.[0]?.matcher).toBe('.*') expect(grokConfig.hooks.StopFailure?.[0]?.matcher).toBeUndefined() - const devinConfig = JSON.parse(devin.fs.files.get('/home/dev/.config/devin/config.json')!) as { + const devinText = devin.fs.files.get('/home/dev/.config/devin/config.json')! + // Why: Devin config.json is JSONC — parse it as such, and assert the user's comment + // survived. Asserting with JSON.parse would only pass if the install had stripped it. + expect(devinText).toContain('// Existing Devin config comment') + const devinConfig = parseJsonc(devinText) as { permissions: { mode: string } hooks: Record } diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 34ef9b68db1..23aef22a7b4 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -7182,6 +7182,303 @@ describe('Last-status persistence', () => { } }) + it('keeps an unknown post-restart child stop from confirming a stale working row', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + await postHookEvent( + firstServer, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'may finish offline' }) + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const restored = server.getStatusSnapshot()[0] + expect(restored).toMatchObject({ state: 'working', restoredUnconfirmed: true }) + + await postHookEvent( + server, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000001' }) + ) + + expect(server.getStatusSnapshot()[0]).toEqual(restored) + expect(server._getStateForTests().claudeSubagentRosterByPaneKey.size).toBe(0) + } finally { + server.stop() + } + }) + + it('waits for a lead boundary after a post-restart stop matches a restored child', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + await postHookEvent( + firstServer, + buildBody({ + hook_event_name: 'SubagentStart', + agent_id: 'a0000000000000002', + agent_type: 'reviewer' + }) + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const statusListener = vi.fn() + server.subscribeEnrichedStatus(statusListener) + const restored = server.getStatusSnapshot()[0] + expect(restored).toMatchObject({ + state: 'working', + restoredUnconfirmed: true, + subagents: [expect.objectContaining({ id: 'a0000000000000002' })] + }) + + await postHookEvent( + server, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000002' }) + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + restoredUnconfirmed: true, + subagents: undefined + }) + expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false) + expect(server._getStateForTests().claudeSubagentRosterByPaneKey.size).toBe(0) + expect(statusListener).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ restoredUnconfirmed: true }) + ) + + await postHookEvent(server, buildBody({ hook_event_name: 'Stop', background_tasks: [] })) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) + expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + expect(statusListener).toHaveBeenCalledTimes(2) + } finally { + server.stop() + } + }) + + it('does not resurrect a matched restored child across a second restart', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + await postHookEvent( + firstServer, + buildBody({ + hook_event_name: 'SubagentStart', + agent_id: 'a0000000000000012', + agent_type: 'reviewer' + }) + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const secondServer = new AgentHookServer() + await secondServer.start({ env: 'production', userDataPath }) + await postHookEvent( + secondServer, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000012' }) + ) + expect(secondServer.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + restoredUnconfirmed: true, + subagents: undefined + }) + secondServer.flushStatusPersistSync() + secondServer.stop() + + const thirdServer = new AgentHookServer() + await thirdServer.start({ env: 'production', userDataPath }) + try { + expect(thirdServer._getStateForTests().claudeSubagentRosterByPaneKey.size).toBe(0) + + await postHookEvent(thirdServer, buildBody({ hook_event_name: 'Stop' })) + + expect(thirdServer.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) + expect(thirdServer.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() + } finally { + thirdServer.stop() + } + }) + + it('keeps a legacy Stop unconfirmed when only a restored child gates it', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + await postHookEvent( + firstServer, + buildBody({ + hook_event_name: 'SubagentStart', + agent_id: 'a0000000000000013', + agent_type: 'reviewer' + }) + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + await postHookEvent(server, buildBody({ hook_event_name: 'Stop' })) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + restoredUnconfirmed: true, + subagents: [expect.objectContaining({ id: 'a0000000000000013' })] + }) + expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false) + } finally { + server.stop() + } + }) + + it('does not confirm restored child work from an unrelated stop', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + await postHookEvent( + firstServer, + buildBody({ + hook_event_name: 'SubagentStart', + agent_id: 'a0000000000000003', + agent_type: 'reviewer' + }) + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const restored = server.getStatusSnapshot()[0] + expect(restored).toMatchObject({ state: 'working', restoredUnconfirmed: true }) + + await postHookEvent( + server, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000004' }) + ) + + expect(server.getStatusSnapshot()[0]).toEqual(restored) + } finally { + server.stop() + } + }) + + it('keeps an unmatched restored child unconfirmed after current-runtime work drains', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + await postHookEvent( + firstServer, + buildBody({ + hook_event_name: 'SubagentStart', + agent_id: 'a0000000000000005', + agent_type: 'reviewer' + }) + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + await postHookEvent( + server, + buildBody({ + hook_event_name: 'SubagentStart', + agent_id: 'a0000000000000006', + agent_type: 'reviewer' + }) + ) + expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() + + await postHookEvent( + server, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000006' }) + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + restoredUnconfirmed: true, + subagents: [expect.objectContaining({ id: 'a0000000000000005' })] + }) + expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false) + + await postHookEvent( + server, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000005' }) + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + restoredUnconfirmed: true + }) + expect(server._getStateForTests().claudeSubagentRosterByPaneKey.size).toBe(0) + + await postHookEvent(server, buildBody({ hook_event_name: 'Stop', background_tasks: [] })) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) + expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + } finally { + server.stop() + } + }) + + it('waits for a lead boundary after a post-restart idle matches a restored teammate', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + await postHookEvent( + firstServer, + buildBody({ + hook_event_name: 'SubagentStart', + agent_id: 'areviewer-6d3cb5b5', + agent_type: 'reviewer' + }) + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const restored = server.getStatusSnapshot()[0] + expect(restored).toMatchObject({ + state: 'working', + restoredUnconfirmed: true, + subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5' })] + }) + + await postHookEvent( + server, + buildBody({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer' }) + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + restoredUnconfirmed: true, + subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5', state: 'idle' })] + }) + expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false) + + await postHookEvent( + server, + buildBody({ + hook_event_name: 'Stop', + background_tasks: [{ id: 'treviewer', type: 'teammate', status: 'running' }] + }) + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5', state: 'idle' })] + }) + expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() + } finally { + server.stop() + } + }) + it('lets an identical live OSC observation confirm a hydrated row', async () => { const payload = { state: 'working' as const, diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 71495245bb1..f819414ad4b 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1277,7 +1277,12 @@ export class AgentHookServer { } const cachedPayload = resolveCachedClaudeCompactOwnership(previous, effectivePayload) const enriched = this.attachStatusTiming(cachedPayload, now) - this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) + // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. + if (enriched.restoredUnconfirmed) { + this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) + } else { + this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) + } this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() diff --git a/src/main/browser/browser-cookie-import-policy.test.ts b/src/main/browser/browser-cookie-import-policy.test.ts index 980c5f78ddb..25204fcd1c7 100644 --- a/src/main/browser/browser-cookie-import-policy.test.ts +++ b/src/main/browser/browser-cookie-import-policy.test.ts @@ -3,7 +3,8 @@ import { DatabaseSync } from 'node:sqlite' import type { Cookie } from 'electron' import { identitiesFromClearCookies, - removeTransplantableCookies + removeTransplantableCookies, + type CookieClearIdentity } from './browser-cookie-import-clear' import { isGoogleSourceBoundCookie, @@ -49,6 +50,29 @@ describe('isGoogleSourceBoundCookie', () => { }) }) +// Why: mirrors what openCookieClearStore returns — get/remove plus the CDP identity pair, and +// deliberately no 'set', so a partition-dropping reconstruction cannot be written against it. +function replaceStore( + existing: Cookie[], + overrides: { + remove?: Mock + snapshot?: Mock + restore?: Mock + } = {} +) { + const get = vi.fn().mockResolvedValue(existing) + const remove = overrides.remove ?? vi.fn().mockResolvedValue(undefined) + const snapshotClearIdentities = + overrides.snapshot ?? + vi + .fn() + .mockImplementation(async (cookies: readonly { cookie: Cookie; url: string }[]) => + identitiesFromClearCookies(cookies) + ) + const restoreClearIdentities = overrides.restore ?? vi.fn().mockResolvedValue(undefined) + return { get, remove, snapshotClearIdentities, restoreClearIdentities } +} + describe('replaceCookiesForImportedDomains', () => { it('removes parent, exact, and child-domain cookies while preserving unrelated sites', async () => { const existing = [ @@ -59,99 +83,129 @@ describe('replaceCookiesForImportedDomains', () => { cookie('.google.com.evil.example', 'suffix-confusion'), cookie('.example.com', 'unrelated') ] - const get = vi.fn().mockResolvedValue(existing) - const remove = vi.fn().mockResolvedValue(undefined) - const set = vi.fn().mockResolvedValue(undefined) + const store = replaceStore(existing) - const removed = await replaceCookiesForImportedDomains({ get, remove, set }, [ - 'accounts.google.com' - ]) + const { removed } = await replaceCookiesForImportedDomains(store, ['accounts.google.com']) expect(removed).toHaveLength(3) - expect(get).toHaveBeenCalledWith({}) - expect(remove.mock.calls).toEqual([ + expect(store.get).toHaveBeenCalledWith({}) + expect(store.remove.mock.calls).toEqual([ ['https://google.com/', 'parent'], ['https://accounts.google.com/signin', 'exact'], ['http://child.accounts.google.com/nested', 'child'] ]) - expect(set).not.toHaveBeenCalled() + // Why: the snapshot must cover the whole removal plan before the first removal runs. + expect(store.snapshotClearIdentities).toHaveBeenCalledOnce() + expect( + store.snapshotClearIdentities.mock.calls[0]?.[0].map( + ({ cookie: entry }: { cookie: Cookie }) => entry.name + ) + ).toEqual(['parent', 'exact', 'child']) + expect(store.restoreClearIdentities).not.toHaveBeenCalled() }) it('does not replace a private-suffix host cookie for a tenant import', async () => { - const get = vi - .fn() - .mockResolvedValue([ - { ...cookie('github.io', 'host-only-suffix'), hostOnly: true }, - cookie('.user.github.io', 'tenant') - ]) - const remove = vi.fn().mockResolvedValue(undefined) - const set = vi.fn().mockResolvedValue(undefined) + const store = replaceStore([ + { ...cookie('github.io', 'host-only-suffix'), hostOnly: true }, + cookie('.user.github.io', 'tenant') + ]) - const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['user.github.io']) + const { removed } = await replaceCookiesForImportedDomains(store, ['user.github.io']) expect(removed.map(({ name }) => name)).toEqual(['tenant']) - expect(remove).toHaveBeenCalledWith('https://user.github.io/', 'tenant') + expect(store.remove).toHaveBeenCalledWith('https://user.github.io/', 'tenant') }) it('does not read or mutate the store when no valid domain scope exists', async () => { - const get = vi.fn() - const remove = vi.fn() - const set = vi.fn() + const store = replaceStore([]) await expect( - replaceCookiesForImportedDomains({ get, remove, set }, [ - '', - '...', - 'com', - 'co.uk', - 'github.io' - ]) - ).resolves.toEqual([]) - expect(get).not.toHaveBeenCalled() - expect(remove).not.toHaveBeenCalled() - expect(set).not.toHaveBeenCalled() + replaceCookiesForImportedDomains(store, ['', '...', 'com', 'co.uk', 'github.io']) + ).resolves.toEqual({ removed: [], identities: [] }) + expect(store.get).not.toHaveBeenCalled() + expect(store.remove).not.toHaveBeenCalled() + expect(store.snapshotClearIdentities).not.toHaveBeenCalled() + expect(store.restoreClearIdentities).not.toHaveBeenCalled() }) it('keeps single-label intranet scopes from selecting descendant hosts', async () => { - const get = vi - .fn() - .mockResolvedValue([cookie('local', 'exact'), cookie('.service.local', 'descendant')]) - const remove = vi.fn().mockResolvedValue(undefined) - const set = vi.fn().mockResolvedValue(undefined) + const store = replaceStore([cookie('local', 'exact'), cookie('.service.local', 'descendant')]) - const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['local']) + const { removed } = await replaceCookiesForImportedDomains(store, ['local']) expect(removed.map(({ name }) => name)).toEqual(['exact']) - expect(remove).toHaveBeenCalledOnce() - expect(remove).toHaveBeenCalledWith('https://local/', 'exact') + expect(store.remove).toHaveBeenCalledOnce() + expect(store.remove).toHaveBeenCalledWith('https://local/', 'exact') }) - it('restores cookies removed before a later removal fails', async () => { + // Why (STA-4097): cookies.get strips partitionKey and cookies.set ignores it, so a rollback + // that rebuilds cookies through the Electron API silently downgrades CHIPS cookies. The undo + // has to travel back through the CDP identities that actually carry the partition. + it('restores removed cookies through CDP identities, keeping partition identity', async () => { const existing = [ cookie('.example.com', 'first', '/one'), cookie('.example.com', 'second', '/two') ] - const get = vi.fn().mockResolvedValue(existing) + const partitionKey = { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true } + const snapshot = vi + .fn() + .mockImplementation(async (cookies: readonly { cookie: Cookie; url: string }[]) => + identitiesFromClearCookies(cookies).map((identity) => + identity.name === 'first' ? { ...identity, partitionKey } : identity + ) + ) const remove = vi .fn() .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error('cookie store unavailable')) - const set = vi.fn().mockResolvedValue(undefined) + const store = replaceStore(existing, { remove, snapshot }) - await expect( - replaceCookiesForImportedDomains({ get, remove, set }, ['example.com']) - ).rejects.toThrow('cookie store unavailable') - expect(set).toHaveBeenCalledOnce() - expect(set).toHaveBeenCalledWith({ - url: 'https://example.com/one', - name: 'first', - value: 'secret', - domain: '.example.com', - path: '/one', - secure: true, - httpOnly: undefined, - sameSite: 'unspecified' - }) + await expect(replaceCookiesForImportedDomains(store, ['example.com'])).rejects.toThrow( + 'cookie store unavailable' + ) + + expect(store.restoreClearIdentities).toHaveBeenCalledOnce() + const restored = store.restoreClearIdentities.mock.calls[0]?.[0] as CookieClearIdentity[] + // Why: the failing coordinate is restored too — a rejected remove cannot prove it survived. + expect(restored.map(({ name }) => name)).toEqual(['second', 'first']) + expect(restored.find(({ name }) => name === 'first')?.partitionKey).toEqual(partitionKey) + expect(restored.find(({ name }) => name === 'first')?.url).toBe('https://example.com/one') + }) + + it('aborts without removing anything when the snapshot cannot cover the removal plan', async () => { + const existing = [ + cookie('.example.com', 'first', '/one'), + cookie('.example.com', 'second', '/two') + ] + const snapshot = vi + .fn() + .mockImplementation(async (cookies: readonly { cookie: Cookie; url: string }[]) => + identitiesFromClearCookies(cookies).filter((identity) => identity.name !== 'second') + ) + const store = replaceStore(existing, { snapshot }) + + await expect(replaceCookiesForImportedDomains(store, ['example.com'])).rejects.toThrow( + 'the session was left unchanged' + ) + expect(store.remove).not.toHaveBeenCalled() + expect(store.restoreClearIdentities).not.toHaveBeenCalled() + }) + + it('reports both failures when the CDP rollback itself fails', async () => { + const remove = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('cookie store unavailable')) + const restore = vi.fn().mockRejectedValue(new Error('debugger detached')) + const store = replaceStore( + [cookie('.example.com', 'first', '/one'), cookie('.example.com', 'second', '/two')], + { remove, restore } + ) + + await expect(replaceCookiesForImportedDomains(store, ['example.com'])).rejects.toThrow( + 'Cookie replacement and rollback failed' + ) + expect(restore).toHaveBeenCalledOnce() }) }) diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts index 9fa3bc5f9b7..4ca88ed6579 100644 --- a/src/main/browser/browser-cookie-import-policy.ts +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -1,5 +1,7 @@ import type { Cookie, Cookies } from 'electron' import { parse as parseDomain } from 'psl' +// Why: type-only, so this does not create a runtime cycle with the clear module. +import type { CookieClearIdentity } from './browser-cookie-import-clear' const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([ 'SIDCC', @@ -167,69 +169,102 @@ export function cookieRemovalUrl(cookie: Cookie, domain: string): string | null } } -export async function restoreImportedDomainCookies( - store: Pick, - cookies: readonly Cookie[] -): Promise { - const failures: unknown[] = [] - for (const cookie of cookies) { - try { - const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null - const url = domain ? cookieRemovalUrl(cookie, domain) : null - if (!url) { - continue - } - await store.set({ - url, - name: cookie.name, - value: cookie.value, - ...(cookie.hostOnly ? {} : { domain: cookie.domain }), - ...(cookie.path ? { path: cookie.path } : {}), - secure: cookie.secure, - httpOnly: cookie.httpOnly, - sameSite: cookie.sameSite, - ...(cookie.expirationDate ? { expirationDate: cookie.expirationDate } : {}) - }) - } catch (err) { - failures.push(err) +// Why (STA-4097): 'set' stays out so the partition-dropping reconstruction cannot return. +// Undoing a removal is only possible through CDP identities, which carry partitionKey. +export type ImportedDomainReplaceStore = Pick & { + snapshotClearIdentities( + cookies: readonly { cookie: Cookie; url: string }[] + ): Promise + restoreClearIdentities(identities: readonly CookieClearIdentity[]): Promise +} + +export type ReplacedImportedDomainCookies = { + removed: Cookie[] + identities: CookieClearIdentity[] +} + +function replaceRemovalKey(url: string, name: string): string { + return JSON.stringify([url, name]) +} + +function assertIdentitiesCoverRemovable( + removable: readonly { cookie: Cookie; url: string }[], + identities: readonly CookieClearIdentity[] +): void { + const covered = new Set( + identities.map((identity) => replaceRemovalKey(identity.url, identity.name)) + ) + for (const item of removable) { + if (!covered.has(replaceRemovalKey(item.url, item.cookie.name))) { + throw new Error('Could not replace existing cookies; the session was left unchanged') } } - if (failures.length > 0) { - throw new AggregateError(failures, 'Could not restore replaced cookies') - } } export async function replaceCookiesForImportedDomains( - store: Pick, + store: ImportedDomainReplaceStore, importedDomains: readonly string[] -): Promise { +): Promise { const scopes = importedDomainScopes(importedDomains) if (scopes.exact.size === 0) { - return [] + return { removed: [], identities: [] } } + // Why (STA-4170): the removal plan is fixed here, beside the identities that can undo it, so + // the restorable set always equals the mutated set. Re-reading the jar later would widen the + // removal past what the snapshot can restore. const existingCookies = await store.get({}) - const removedCookies: Cookie[] = [] + const removable: { cookie: Cookie; url: string }[] = [] for (const cookie of existingCookies) { const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null if (!domain || !overlapsImportedDomain(cookie, domain, scopes)) { continue } const url = cookieRemovalUrl(cookie, domain) - if (!url) { - continue + if (url) { + removable.push({ cookie, url }) + } + } + if (removable.length === 0) { + return { removed: [], identities: [] } + } + + // Why: snapshotting before the first removal is what makes the rollback lossless; an + // incomplete snapshot aborts while the session is still untouched. + const identities = await store.snapshotClearIdentities(removable) + assertIdentitiesCoverRemovable(removable, identities) + const identitiesByKey = new Map() + for (const identity of identities) { + const key = replaceRemovalKey(identity.url, identity.name) + const group = identitiesByKey.get(key) ?? [] + group.push(identity) + identitiesByKey.set(key, group) + } + + const removed: Cookie[] = [] + // Why: one remove(url, name) deletes every cookie at that coordinate, partitioned twins + // included, so the rollback set is tracked per coordinate rather than per cookie. + const attemptedKeys = new Set() + const attemptedIdentities: CookieClearIdentity[] = [] + for (const { cookie, url } of removable) { + const key = replaceRemovalKey(url, cookie.name) + if (!attemptedKeys.has(key)) { + attemptedKeys.add(key) + attemptedIdentities.push(...(identitiesByKey.get(key) ?? [])) } try { await store.remove(url, cookie.name) - removedCookies.push(cookie) + removed.push(cookie) } catch (err) { try { - await restoreImportedDomainCookies(store, removedCookies) + // Why: the failing coordinate is included because a rejected remove cannot prove the + // cookie survived; restoring a live cookie rewrites the value it was snapshotted with. + await store.restoreClearIdentities(attemptedIdentities.toReversed()) } catch (restoreError) { throw new AggregateError([err, restoreError], 'Cookie replacement and rollback failed') } throw err } } - return removedCookies + return { removed, identities } } diff --git a/src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts b/src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts new file mode 100644 index 00000000000..bcb16ad9cfe --- /dev/null +++ b/src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts @@ -0,0 +1,293 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { build as buildVite } from 'vite' + +const electronBinary = createRequire(import.meta.url)('electron') as string +const fixtureRoots: string[] = [] + +afterAll(() => { + for (const root of fixtureRoots) { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +type CdpCookie = { name: string; value: string; partitionKey?: Record } + +type FixtureResult = { + beforePartitionKey: Record | undefined + electronGetSawPartitionKey: boolean + removeCalls: number + removeCallsBeforeThrow: number + snapshotCalls: number + restoreCalls: number + importedRemoveCalls: number + replaceError: string | null + chipsAfterReplace: CdpCookie[] + chipsAfter: CdpCookie[] + plainAfter: CdpCookie[] +} + +const EXPECTED_PARTITION_KEY = { + topLevelSite: 'https://top.example', + hasCrossSiteAncestor: true +} + +type FixtureMode = 'site-a' | 'site-b' + +function buildFixtureMain(bundlePath: string, resultPath: string, mode: FixtureMode): string { + return ` +const { app, BrowserWindow, session } = require('electron') +const { writeFileSync } = require('node:fs') +const { openCookieClearStore, replaceCookiesForImportedDomains } = require(${JSON.stringify(bundlePath)}) +const resultPath = ${JSON.stringify(resultPath)} +const mode = ${JSON.stringify(mode)} +const expectedPartitionKey = ${JSON.stringify(EXPECTED_PARTITION_KEY)} +let currentStep = 'starting' +const mark = (step) => { + currentStep = step + writeFileSync(resultPath, JSON.stringify({ step })) +} + +const project = (cookies, name) => cookies + .filter((cookie) => cookie.name === name) + .map((cookie) => ({ name: cookie.name, value: cookie.value, partitionKey: cookie.partitionKey })) + +async function run() { + const timeout = setTimeout(() => { + writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep })) + app.exit(1) + }, 20000) + await app.whenReady() + mark('ready') + const partition = 'persist:replace-partition-rollback-' + mode + const targetSession = session.fromPartition(partition) + const window = new BrowserWindow({ show: false, webPreferences: { partition } }) + mark('window created') + await window.loadURL('data:text/html,replace partition rollback fixture') + mark('window loaded') + const debug = window.webContents.debugger + debug.attach('1.3') + mark('debugger attached') + + // Only CDP can create a partitioned cookie; Electron's cookies API has no partitionKey. + await debug.sendCommand('Network.setCookie', { + url: 'https://app.acme-chips.test/', + name: 'chips-auth', + value: 'keep-me', + secure: true, + sameSite: 'None', + partitionKey: expectedPartitionKey + }) + const beforeChips = (await debug.sendCommand('Network.getAllCookies')).cookies + .find((cookie) => cookie.name === 'chips-auth') + if (!beforeChips || !beforeChips.partitionKey) { + throw new Error('CHIPS fixture cookie was not stored partitioned') + } + mark('partitioned cookie set') + + await targetSession.cookies.set({ + url: 'https://plain.acme-chips.test/', + name: 'plain', + value: 'original', + secure: true + }) + await targetSession.cookies.set({ + url: 'https://victim.acme-chips.test/', + name: 'victim', + value: 'original', + secure: true + }) + mark('ordinary cookies set') + + let removeCalls = 0 + let removeCallsBeforeThrow = 0 + let snapshotCalls = 0 + let restoreCalls = 0 + let importedRemoveCalls = 0 + let chipsRemoved = false + let electronGetSawPartitionKey = false + let replaceError = null + let chipsAfterReplace = [] + + const cookieClearStore = openCookieClearStore(targetSession) + const store = { + get: async (filter) => { + const cookies = await targetSession.cookies.get(filter) + // Why: proves the Electron API cannot see the partition it is about to destroy. + electronGetSawPartitionKey = cookies.some( + (cookie) => cookie.name === 'chips-auth' && cookie.partitionKey !== undefined + ) + // Why: cookies.get order is not a contract; put CHIPS first so it is provably already + // removed by the time a later removal rejects. + return [...cookies].sort((left, right) => { + if (left.name === 'chips-auth') return -1 + if (right.name === 'chips-auth') return 1 + return 0 + }) + }, + remove: async (url, name) => { + removeCalls++ + if (mode === 'site-a' && chipsRemoved) { + removeCallsBeforeThrow = removeCalls - 1 + throw new Error('forced removal failure after chips was removed') + } + await targetSession.cookies.remove(url, name) + if (name === 'chips-auth') chipsRemoved = true + }, + snapshotClearIdentities: async (cookies) => { + snapshotCalls++ + return cookieClearStore.snapshotClearIdentities(cookies) + }, + restoreClearIdentities: async (identities) => { + restoreCalls++ + return cookieClearStore.restoreClearIdentities(identities) + } + } + + try { + if (mode === 'site-a') { + try { + await replaceCookiesForImportedDomains(store, ['acme-chips.test']) + } catch (error) { + replaceError = String(error && error.message ? error.message : error) + } + mark('site-a replace finished') + } else { + const replaced = await replaceCookiesForImportedDomains(store, ['acme-chips.test']) + chipsAfterReplace = project((await debug.sendCommand('Network.getAllCookies')).cookies, 'chips-auth') + mark('site-b replace finished') + // Why: stands in for importValidatedCookies' mid-import cookies.set rejection — the + // imported cookie is removed (lossless) and the user's originals go back through CDP. + await targetSession.cookies.set({ + url: 'https://imported.acme-chips.test/', + name: 'imported-new', + value: 'new', + secure: true + }) + await targetSession.cookies.remove('https://imported.acme-chips.test/', 'imported-new') + importedRemoveCalls = 1 + mark('site-b imported cookies rolled back') + restoreCalls++ + await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed()) + mark('site-b restore finished') + } + } finally { + cookieClearStore.dispose() + } + + const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies + clearTimeout(timeout) + writeFileSync(resultPath, JSON.stringify({ + beforePartitionKey: beforeChips.partitionKey, + electronGetSawPartitionKey, + removeCalls, + removeCallsBeforeThrow, + snapshotCalls, + restoreCalls, + importedRemoveCalls, + replaceError, + chipsAfterReplace, + chipsAfter: project(afterCookies, 'chips-auth'), + plainAfter: project(afterCookies, 'plain') + })) + debug.detach() + window.destroy() + app.exit(0) +} + +run().catch((error) => { + writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error && error.stack ? error.stack : error) })) + app.exit(1) +}) +` +} + +async function runFixture(mode: FixtureMode): Promise { + const root = mkdtempSync(join(tmpdir(), `orca-replace-partition-rollback-${mode}-`)) + fixtureRoots.push(root) + const bundlePath = join(root, 'cookie-replace-rollback.cjs') + const bundleEntryPath = join(root, 'cookie-replace-rollback.ts') + const resultPath = join(root, 'result.json') + const fixturePath = join(root, 'main.cjs') + writeFileSync( + bundleEntryPath, + [ + `export { openCookieClearStore } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-clear-store.ts'))}`, + `export { replaceCookiesForImportedDomains } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import-policy.ts'))}` + ].join('\n') + ) + await buildVite({ + configFile: false, + logLevel: 'silent', + build: { + emptyOutDir: false, + lib: { + entry: bundleEntryPath, + formats: ['cjs'], + fileName: () => 'cookie-replace-rollback.cjs' + }, + outDir: root, + target: 'node20', + rollupOptions: { external: ['electron', /^node:/] } + } + }) + writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath, mode)) + const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env + const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`] + const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary + const args = + process.platform === 'linux' + ? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox'] + : electronArgs + const run = spawnSync(executable, args, { + encoding: 'utf8', + env, + timeout: 60_000 + }) + const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' + expect(run.error).toBeUndefined() + expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0) + return JSON.parse(fixtureResult) as FixtureResult +} + +describe('partitioned cookies under a failed replace-imported-domains import', () => { + // Why (STA-4097): this rollback undoes deletions the import already made to the user's own + // cookies. Rebuilding them with cookies.set dropped partitionKey silently, so a CHIPS cookie + // came back as an ordinary one and no restart recovered it. + it('site A: restores a CHIPS cookie with its partition after a later removal rejects', async () => { + const result = await runFixture('site-a') + + expect(result.beforePartitionKey).toEqual(EXPECTED_PARTITION_KEY) + // Why: without this the fixture could pass on a cookie that was never partitioned. + expect(result.electronGetSawPartitionKey).toBe(false) + // Why: assert the rollback was reached, not just that the jar looks right at the end. + expect(result.snapshotCalls).toBe(1) + expect(result.removeCallsBeforeThrow).toBeGreaterThanOrEqual(1) + expect(result.restoreCalls).toBe(1) + expect(result.replaceError).toContain('forced removal failure after chips was removed') + expect(result.chipsAfter).toEqual([ + { name: 'chips-auth', value: 'keep-me', partitionKey: EXPECTED_PARTITION_KEY } + ]) + expect(result.plainAfter).toEqual([{ name: 'plain', value: 'original' }]) + }, 90_000) + + it('site B: restores a CHIPS cookie with its partition after a mid-import set failure', async () => { + const result = await runFixture('site-b') + + expect(result.beforePartitionKey).toEqual(EXPECTED_PARTITION_KEY) + expect(result.electronGetSawPartitionKey).toBe(false) + expect(result.snapshotCalls).toBe(1) + // Why: the replace really emptied the scope, so the restore below is doing the work. + expect(result.chipsAfterReplace).toEqual([]) + expect(result.importedRemoveCalls).toBe(1) + expect(result.restoreCalls).toBe(1) + expect(result.chipsAfter).toEqual([ + { name: 'chips-auth', value: 'keep-me', partitionKey: EXPECTED_PARTITION_KEY } + ]) + expect(result.plainAfter).toEqual([{ name: 'plain', value: 'original' }]) + }, 90_000) +}) diff --git a/src/main/browser/browser-cookie-import-replacement.test.ts b/src/main/browser/browser-cookie-import-replacement.test.ts index a727a8275f5..d0e79ec1c7f 100644 --- a/src/main/browser/browser-cookie-import-replacement.test.ts +++ b/src/main/browser/browser-cookie-import-replacement.test.ts @@ -3,15 +3,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { appGetPathMock, clearPendingCookieImportMock, + disposeClearStoreMock, execFileSyncMock, + restoreClearIdentitiesMock, sessionFromPartitionMock, - setPendingCookieImportMock + setPendingCookieImportMock, + snapshotClearIdentitiesMock } = vi.hoisted(() => ({ appGetPathMock: vi.fn(), clearPendingCookieImportMock: vi.fn(), + disposeClearStoreMock: vi.fn(), execFileSyncMock: vi.fn(), + restoreClearIdentitiesMock: vi.fn(), sessionFromPartitionMock: vi.fn(), - setPendingCookieImportMock: vi.fn() + setPendingCookieImportMock: vi.fn(), + snapshotClearIdentitiesMock: vi.fn() })) vi.mock('./browser-session-registry', () => ({ @@ -26,6 +32,7 @@ vi.mock('electron', () => ({ dialog: { showOpenDialog: vi.fn() }, session: { fromPartition: sessionFromPartitionMock } })) +// Why: snapshot/restore are spies, not no-ops, so a rollback that never ran cannot pass as one. vi.mock('./browser-cookie-clear-store', () => ({ openCookieClearStore: (targetSession: { cookies: { @@ -35,10 +42,9 @@ vi.mock('./browser-cookie-clear-store', () => ({ }) => ({ get: (filter: object) => targetSession.cookies.get(filter), remove: (url: string, name: string) => targetSession.cookies.remove(url, name), - snapshotClearIdentities: async (items: { cookie: Record; url: string }[]) => - items.map(({ cookie, url }) => ({ url, ...cookie })), - restoreClearIdentities: async () => undefined, - dispose: () => undefined + snapshotClearIdentities: snapshotClearIdentitiesMock, + restoreClearIdentities: restoreClearIdentitiesMock, + dispose: disposeClearStoreMock }) })) @@ -48,6 +54,16 @@ import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +beforeEach(() => { + snapshotClearIdentitiesMock + .mockReset() + .mockImplementation(async (items: { cookie: Record; url: string }[]) => + items.map(({ cookie: entry, url }) => ({ url, ...entry })) + ) + restoreClearIdentitiesMock.mockReset().mockResolvedValue(undefined) + disposeClearStoreMock.mockReset() +}) + describe('validated cookie replacement', () => { let cookiesGetMock: ReturnType let cookiesRemoveMock: ReturnType @@ -139,8 +155,17 @@ describe('validated cookie replacement', () => { expect(cookiesSetMock).not.toHaveBeenCalled() }) - it('restores the previous snapshot when an incoming cookie is rejected', async () => { + // Why (STA-4097): this rollback puts back the user's ORIGINAL cookies, which the import had + // already deleted. cookies.get drops partitionKey and cookies.set ignores it, so rebuilding + // them through the Electron API resurrected CHIPS cookies unpartitioned. The undo now travels + // back through the CDP identities, which are the only thing that carries the partition. + it('restores the previous cookies through CDP identities when an incoming cookie is rejected', async () => { + const partitionKey = { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true } cookiesGetMock.mockResolvedValue([cookie('.example.com', 'existing')]) + snapshotClearIdentitiesMock.mockImplementation( + async (items: { cookie: Record; url: string }[]) => + items.map(({ cookie: entry, url }) => ({ url, ...entry, partitionKey })) + ) cookiesSetMock .mockResolvedValue(undefined) .mockResolvedValueOnce(undefined) @@ -157,16 +182,35 @@ describe('validated cookie replacement', () => { ['https://example.com/', 'existing'], ['https://example.com/', 'first'] ]) - expect(cookiesSetMock).toHaveBeenLastCalledWith({ - url: 'https://example.com/', - name: 'existing', - value: 'old', - domain: '.example.com', - path: '/', - secure: true, - httpOnly: undefined, - sameSite: 'unspecified' - }) + expect(restoreClearIdentitiesMock).toHaveBeenCalledOnce() + expect(restoreClearIdentitiesMock.mock.calls[0]?.[0]).toEqual([ + { url: 'https://example.com/', ...cookie('.example.com', 'existing'), partitionKey } + ]) + // Why: cookies.set is only ever the two imported cookies — a third call would mean the + // partition-dropping reconstruction came back. + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['first', 'second']) + expect(disposeClearStoreMock).toHaveBeenCalledOnce() + }) + + // Why: restoreClearIdentities attaches a debugger before it iterates, so calling it with an + // empty restore set would create a hidden BrowserWindow to put nothing back. + it('does not reach for CDP when the rollback has nothing to restore', async () => { + cookiesGetMock.mockResolvedValue([]) + cookiesSetMock + .mockResolvedValue(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('cookie rejected')) + const filePath = writeCookies([ + { domain: '.example.com', name: 'first', value: 'new', secure: true }, + { domain: '.example.com', name: 'second', value: 'new', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok).toBe(false) + // Why: the imported cookie is still removed — that half of the rollback is lossless. + expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'first']]) + expect(restoreClearIdentitiesMock).not.toHaveBeenCalled() }) it('fails closed when existing cookies cannot be replaced', async () => { diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index b54badd0593..2e2f50dbdd1 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */ -import { app, type BrowserWindow, type Cookie, dialog, session } from 'electron' +import { app, type BrowserWindow, dialog, session } from 'electron' import { execFileSync } from 'node:child_process' import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto' import { @@ -81,8 +81,8 @@ import { normalizeCookieDomain, normalizeCookieImportDomain, replaceCookiesForImportedDomains, - restoreImportedDomainCookies, - type CookieImportMode + type CookieImportMode, + type ReplacedImportedDomainCookies } from './browser-cookie-import-policy' import { removeTransplantableCookies, withCookieClearLock } from './browser-cookie-import-clear' import { openCookieClearStore } from './browser-cookie-clear-store' @@ -594,97 +594,112 @@ async function importValidatedCookies( let importedCount = 0 let skipped = totalInput - importableCookies.length const domainSet = new Set() - let replacedCookies: Cookie[] | null = null + let replaced: ReplacedImportedDomainCookies | null = null + // Why (STA-4097): the rollback below has to put back cookies this import already deleted, and + // only CDP identities carry partitionKey — rebuilding them with cookies.set drops it silently. + const cookieClearStore = + mode === 'replace-imported-domains' && importableCookies.length > 0 + ? openCookieClearStore(targetSession) + : null - if (mode === 'replace-imported-domains' && importableCookies.length > 0) { - try { - replacedCookies = await replaceCookiesForImportedDomains( - targetSession.cookies, - importableCookies.map((cookie) => cookie.domain) - ) - diag(` removed ${replacedCookies.length} existing cookies in imported domain scopes`) - } catch (err) { - diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) + try { + if (cookieClearStore) { + try { + replaced = await replaceCookiesForImportedDomains( + cookieClearStore, + importableCookies.map((cookie) => cookie.domain) + ) + diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`) + } catch (err) { + diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) + return { + ok: false, + reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') + } + } + } + + // Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net. + const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') + const importedCookieKeys: { url: string; name: string }[] = [] + let setFailure: unknown = null + + for (const cookie of importableCookies) { + try { + // Why: Chromium rejects __Host- cookies unless they omit domain and use path=/. + const isHostPrefixed = cookie.name.startsWith('__Host-') + const path = isHostPrefixed ? '/' : cookie.path + await targetSession.cookies.set({ + url: cookie.url, + name: cookie.name, + value: stripNonPrintable(cookie.value), + ...(isHostPrefixed ? {} : { domain: cookie.domain }), + path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + expirationDate: cookie.expirationDate + }) + const removalUrl = new URL(cookie.url) + removalUrl.pathname = path.startsWith('/') ? path : '/' + importedCookieKeys.push({ url: removalUrl.toString(), name: cookie.name }) + importedCount++ + // Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data. + const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain + domainSet.add(cleanDomain) + } catch (err) { + skipped++ + setFailure = err + if (skipped <= 5) { + // Find the exact offending character position and code + const val = cookie.value + let badInfo = 'none found' + for (let i = 0; i < val.length; i++) { + const code = val.charCodeAt(i) + if (code < 0x20 || code > 0x7e) { + badInfo = `pos=${i} char=U+${code.toString(16).padStart(4, '0')}` + break + } + } + diag( + ` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}` + ) + } + if (replaced) { + break + } + } + } + + // Why: replaced is only ever set alongside cookieClearStore, which owns the CDP restore. + if (setFailure && replaced && cookieClearStore) { + const rollbackFailures: unknown[] = [] + for (const cookie of importedCookieKeys.toReversed()) { + try { + await targetSession.cookies.remove(cookie.url, cookie.name) + } catch (err) { + rollbackFailures.push(err) + } + } + // Why: restoreClearIdentities attaches the debugger before it iterates, so an empty + // restore set would spin up a hidden BrowserWindow to put nothing back. + if (replaced.identities.length > 0) { + try { + await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed()) + } catch (err) { + rollbackFailures.push(err) + } + } + if (rollbackFailures.length > 0) { + diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) + } return { ok: false, - reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') + reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') } } - } - - // Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net. - const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') - const importedCookieKeys: { url: string; name: string }[] = [] - let setFailure: unknown = null - - for (const cookie of importableCookies) { - try { - // Why: Chromium rejects __Host- cookies unless they omit domain and use path=/. - const isHostPrefixed = cookie.name.startsWith('__Host-') - const path = isHostPrefixed ? '/' : cookie.path - await targetSession.cookies.set({ - url: cookie.url, - name: cookie.name, - value: stripNonPrintable(cookie.value), - ...(isHostPrefixed ? {} : { domain: cookie.domain }), - path, - secure: cookie.secure, - httpOnly: cookie.httpOnly, - sameSite: cookie.sameSite, - expirationDate: cookie.expirationDate - }) - const removalUrl = new URL(cookie.url) - removalUrl.pathname = path.startsWith('/') ? path : '/' - importedCookieKeys.push({ url: removalUrl.toString(), name: cookie.name }) - importedCount++ - // Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data. - const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain - domainSet.add(cleanDomain) - } catch (err) { - skipped++ - setFailure = err - if (skipped <= 5) { - // Find the exact offending character position and code - const val = cookie.value - let badInfo = 'none found' - for (let i = 0; i < val.length; i++) { - const code = val.charCodeAt(i) - if (code < 0x20 || code > 0x7e) { - badInfo = `pos=${i} char=U+${code.toString(16).padStart(4, '0')}` - break - } - } - diag( - ` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}` - ) - } - if (replacedCookies) { - break - } - } - } - - if (setFailure && replacedCookies) { - const rollbackFailures: unknown[] = [] - for (const cookie of importedCookieKeys.toReversed()) { - try { - await targetSession.cookies.remove(cookie.url, cookie.name) - } catch (err) { - rollbackFailures.push(err) - } - } - try { - await restoreImportedDomainCookies(targetSession.cookies, replacedCookies) - } catch (err) { - rollbackFailures.push(err) - } - if (rollbackFailures.length > 0) { - diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) - } - return { - ok: false, - reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') - } + } finally { + cookieClearStore?.dispose() } diag( diff --git a/src/main/cli/bundled-cli-launcher-path.ts b/src/main/cli/bundled-cli-launcher-path.ts new file mode 100644 index 00000000000..1271198b946 --- /dev/null +++ b/src/main/cli/bundled-cli-launcher-path.ts @@ -0,0 +1,23 @@ +import { join } from 'node:path' + +// Why `orca-ide` on Linux: GNOME Orca ships /usr/bin/orca, so the CLI never claims that name. +export const LINUX_CLI_COMMAND_NAME = 'orca-ide' + +/** Absolute path of the CLI launcher this app ships in its own resources bundle. + * Lives apart from cli-installer so callers that only need the path (PTY env + * assembly) don't pull in the installer's `electron` dependency. */ +export function getBundledLauncherPath( + platform: NodeJS.Platform, + resourcesPath: string +): string | null { + if (platform === 'darwin') { + return join(resourcesPath, 'bin', 'orca') + } + if (platform === 'linux') { + return join(resourcesPath, 'bin', LINUX_CLI_COMMAND_NAME) + } + if (platform === 'win32') { + return join(resourcesPath, 'bin', 'orca.exe') + } + return null +} diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 74c8dbcfb77..a57fdf95924 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -19,6 +19,7 @@ import { promisify } from 'node:util' import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types' import { expandWindowsEnvironmentVariables } from '../../shared/windows-environment-expansion' import { buildAppImageCliWrapper } from './appimage-cli-wrapper' +import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' import { invalidateWindowsUserPathRegistryCache, readFreshWindowsUserPathRegistry, @@ -29,7 +30,6 @@ import { const execFileAsync = promisify(execFile) const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca' const DEV_COMMAND_NAME = 'orca-dev' -const LINUX_COMMAND_NAME = 'orca-ide' const LEGACY_LINUX_COMMAND_NAME = 'orca' const DEV_LAUNCHER_DIR = ['cli', 'bin'] const WINDOWS_PATH_WRITE_TIMEOUT_MS = 5_000 @@ -88,7 +88,7 @@ export class CliInstaller { return DEV_COMMAND_NAME } // Why: packaged Linux uses `orca-ide` to avoid shadowing GNOME Orca's /usr/bin/orca. - return this.platform === 'linux' ? LINUX_COMMAND_NAME : 'orca' + return this.platform === 'linux' ? LINUX_CLI_COMMAND_NAME : 'orca' } constructor(options: CliInstallerOptions = {}) { @@ -351,7 +351,7 @@ export class CliInstaller { if (this.platform === 'linux') { // Why: Linux lacks a privileged global command flow; ~/.local/bin is the least-surprising user-scoped dir. // Why `orca-ide`: GNOME Orca ships /usr/bin/orca, so avoid shadowing that screen reader. - return join(this.homePath, '.local', 'bin', LINUX_COMMAND_NAME) + return join(this.homePath, '.local', 'bin', LINUX_CLI_COMMAND_NAME) } if (this.platform === 'win32') { @@ -1200,18 +1200,4 @@ function quotePowerShell(value: string): string { return `'${value.replaceAll("'", "''")}'` } -export function getBundledLauncherPath( - platform: NodeJS.Platform, - resourcesPath: string -): string | null { - if (platform === 'darwin') { - return join(resourcesPath, 'bin', 'orca') - } - if (platform === 'linux') { - return join(resourcesPath, 'bin', LINUX_COMMAND_NAME) - } - if (platform === 'win32') { - return join(resourcesPath, 'bin', 'orca.exe') - } - return null -} +export { getBundledLauncherPath } diff --git a/src/main/daemon/daemon-checkpoint-session-queue.test.ts b/src/main/daemon/daemon-checkpoint-session-queue.test.ts index b7455d579fe..158d1891f9a 100644 --- a/src/main/daemon/daemon-checkpoint-session-queue.test.ts +++ b/src/main/daemon/daemon-checkpoint-session-queue.test.ts @@ -4,12 +4,18 @@ import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue' -function deferred(): { promise: Promise; resolve: (value: T) => void } { +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { let resolve!: (value: T) => void - const promise = new Promise((res) => { + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { resolve = res + reject = rej }) - return { promise, resolve } + return { promise, reject, resolve } } describe('CheckpointSessionQueue', () => { @@ -57,6 +63,7 @@ describe('CheckpointSessionQueue', () => { const queue = new CheckpointSessionQueue() const stalled = deferred() let completed = false + const onDeadlineFired = vi.fn() const outcome = await queue.runWithDeadline( 'stalled', @@ -66,10 +73,12 @@ describe('CheckpointSessionQueue', () => { return 'durable' }, 5, - 'live' + 'live', + { onDeadline: onDeadlineFired } ) expect(outcome).toBe('live') + expect(onDeadlineFired).toHaveBeenCalledOnce() // Why: abandoning the wait must not abandon the write, or a reattach deadline // would drop durable history instead of merely delaying it. expect(completed).toBe(false) @@ -77,6 +86,47 @@ describe('CheckpointSessionQueue', () => { await vi.waitFor(() => expect(completed).toBe(true)) }) + it('propagates operation failures instead of reporting a deadline', async () => { + const queue = new CheckpointSessionQueue() + const onDeadlineFired = vi.fn() + + await expect( + queue.runWithDeadline( + 'failed', + async () => { + throw new Error('daemon unavailable') + }, + 50, + 'timed-out', + { onDeadline: onDeadlineFired } + ) + ).rejects.toThrow('daemon unavailable') + expect(onDeadlineFired).not.toHaveBeenCalled() + }) + + it('reports an operation rejection that arrives after the deadline', async () => { + const queue = new CheckpointSessionQueue() + const stalled = deferred() + const onAbandonedRejection = vi.fn() + + await expect( + queue.runWithDeadline( + 'failed-late', + async () => await stalled.promise, + 5, + 'timed-out', + { onAbandonedRejection } + ) + ).resolves.toBe('timed-out') + + stalled.reject(new Error('late failure')) + await vi.waitFor(() => + expect(onAbandonedRejection).toHaveBeenCalledWith( + expect.objectContaining({ message: 'late failure' }) + ) + ) + }) + it('keeps a later waiter behind an abandoned operation', async () => { const queue = new CheckpointSessionQueue() const stalled = deferred() diff --git a/src/main/daemon/daemon-checkpoint-session-queue.ts b/src/main/daemon/daemon-checkpoint-session-queue.ts index b2bc1f7538b..6403299c69e 100644 --- a/src/main/daemon/daemon-checkpoint-session-queue.ts +++ b/src/main/daemon/daemon-checkpoint-session-queue.ts @@ -14,6 +14,11 @@ /** Running plus queued operations one session may hold before callers are turned away. */ export const CHECKPOINT_SESSION_QUEUE_MAX_PENDING = 3 +type DeadlineCallbacks = { + onDeadline?: () => void + onAbandonedRejection?: (error: unknown) => void +} + export class CheckpointSessionQueue { private tails = new Map>() private pending = new Map() @@ -36,20 +41,33 @@ export class CheckpointSessionQueue { sessionId: string, operation: () => Promise, deadlineMs: number, - onDeadline: T + onDeadline: T, + callbacks: DeadlineCallbacks = {} ): Promise { const work = this.enqueue(sessionId, operation) - return new Promise((resolve) => { - const timer = setTimeout(() => resolve(onDeadline), deadlineMs) + return new Promise((resolve, reject) => { + let deadlineFired = false + const timer = setTimeout(() => { + deadlineFired = true + try { + callbacks.onDeadline?.() + } finally { + resolve(onDeadline) + } + }, deadlineMs) timer.unref?.() work.then( (value) => { clearTimeout(timer) resolve(value) }, - () => { + (error: unknown) => { clearTimeout(timer) - resolve(onDeadline) + if (deadlineFired) { + callbacks.onAbandonedRejection?.(error) + return + } + reject(error) } ) }) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 49f2915b7df..df99d223cd5 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -2525,6 +2525,180 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(checkpoint).not.toHaveBeenCalled() }) + it('keeps abandoned periodic checkpoints within the global work cap', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { + PERIODIC_CHECKPOINT_DEADLINE_MS: number + } + const previousDeadline = adapterClass.PERIODIC_CHECKPOINT_DEADLINE_MS + adapterClass.PERIODIC_CHECKPOINT_DEADLINE_MS = 5 + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const releases: (() => void)[] = [] + const requestedSessionIds: string[] = [] + const request = vi.fn(async (_type: string, payload: { sessionId: string }) => { + requestedSessionIds.push(payload.sessionId) + await new Promise((resolve) => releases.push(resolve)) + return { + records: [{ kind: 'output', data: payload.sessionId }], + seq: 1, + overflowed: false, + snapshot: null + } + }) + const appendIncrements = vi.fn(async () => 'ok' as const) + const internals = historyAdapter as unknown as { + client: { request: typeof request; disconnect: ReturnType } + historyManager: { + checkpoint: ReturnType + appendIncrements: typeof appendIncrements + dispose: ReturnType + } + checkpointSessions(sessionIds: Iterable): Promise> + nonFinalCheckpointAdmissionSessionIds: Set + tryAdmitNonFinalCheckpoint(sessionId: string): boolean + } + internals.client = { request, disconnect: vi.fn() } + internals.historyManager = { + checkpoint: vi.fn(async () => 'committed' as const), + appendIncrements, + dispose: vi.fn(async () => {}) + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const tryAdmit = vi.spyOn(internals, 'tryAdmitNonFinalCheckpoint') + + try { + const checkpointing = internals.checkpointSessions(['a', 'b', 'c', 'd', 'e', 'f']) + await waitFor(() => requestedSessionIds.length === 4) + await expect(checkpointing).resolves.toEqual(new Set()) + + expect(requestedSessionIds).toEqual(['a', 'b', 'c', 'd']) + expect(tryAdmit).toHaveBeenCalledTimes(4) + expect(internals.nonFinalCheckpointAdmissionSessionIds).toEqual( + new Set(['a', 'b', 'c', 'd']) + ) + expect(warn).toHaveBeenCalledWith('[history] periodic checkpoint deadline exceeded:', 'a') + + for (const release of releases) { + release() + } + await waitFor(() => internals.nonFinalCheckpointAdmissionSessionIds.size === 0) + } finally { + adapterClass.PERIODIC_CHECKPOINT_DEADLINE_MS = previousDeadline + warn.mockRestore() + } + }) + + it('lets one session hold only one global non-final admission', () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const internals = historyAdapter as unknown as { + tryAdmitNonFinalCheckpoint(sessionId: string): boolean + releaseNonFinalCheckpointAdmission(sessionId: string): void + nonFinalCheckpointAdmissionSessionIds: Set + nonFinalAdmissionDeniedSessionIds: Set + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + expect(internals.tryAdmitNonFinalCheckpoint('stalled')).toBe(true) + expect(internals.tryAdmitNonFinalCheckpoint('stalled')).toBe(false) + expect(warn).toHaveBeenCalledWith( + '[history] non-final checkpoint already in flight:', + 'stalled' + ) + expect(internals.tryAdmitNonFinalCheckpoint('healthy-a')).toBe(true) + expect(internals.tryAdmitNonFinalCheckpoint('healthy-b')).toBe(true) + expect(internals.tryAdmitNonFinalCheckpoint('healthy-c')).toBe(true) + expect(internals.tryAdmitNonFinalCheckpoint('overflow')).toBe(false) + expect(internals.tryAdmitNonFinalCheckpoint('another-overflow')).toBe(false) + + expect(internals.nonFinalCheckpointAdmissionSessionIds).toEqual( + new Set(['stalled', 'healthy-a', 'healthy-b', 'healthy-c']) + ) + expect(internals.nonFinalAdmissionDeniedSessionIds).toEqual(new Set(['stalled'])) + expect(warn).toHaveBeenCalledWith( + '[history] non-final checkpoint global admission limit reached:', + 'overflow' + ) + expect( + warn.mock.calls.filter( + ([message]) => + message === '[history] non-final checkpoint global admission limit reached:' + ) + ).toHaveLength(1) + + internals.releaseNonFinalCheckpointAdmission('stalled') + expect(internals.nonFinalAdmissionDeniedSessionIds).toEqual(new Set()) + expect(internals.tryAdmitNonFinalCheckpoint('overflow')).toBe(true) + } finally { + warn.mockRestore() + } + }) + + it('logs non-final checkpoint RPC failures', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const request = vi.fn(async () => { + throw new Error('daemon socket unavailable') + }) + const internals = historyAdapter as unknown as { + client: { request: typeof request; disconnect: ReturnType } + checkpointSessions(sessionIds: Iterable): Promise> + } + internals.client = { request, disconnect: vi.fn() } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + await expect(internals.checkpointSessions(['broken'])).resolves.toEqual(new Set()) + expect(warn).toHaveBeenCalledWith( + '[history] checkpoint failed:', + 'broken', + expect.objectContaining({ message: 'daemon socket unavailable' }) + ) + } finally { + warn.mockRestore() + } + }) + + it('logs a periodic checkpoint failure that arrives after its deadline', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { + PERIODIC_CHECKPOINT_DEADLINE_MS: number + } + const previousDeadline = adapterClass.PERIODIC_CHECKPOINT_DEADLINE_MS + adapterClass.PERIODIC_CHECKPOINT_DEADLINE_MS = 5 + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + let rejectRequest!: (error: unknown) => void + const request = vi.fn( + async () => + await new Promise((_resolve, reject) => { + rejectRequest = reject + }) + ) + const internals = historyAdapter as unknown as { + client: { request: typeof request; disconnect: ReturnType } + checkpointSessions(sessionIds: Iterable): Promise> + } + internals.client = { request, disconnect: vi.fn() } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const checkpointing = internals.checkpointSessions(['late-failure']) + await waitFor(() => request.mock.calls.length === 1) + await expect(checkpointing).resolves.toEqual(new Set()) + + rejectRequest(new Error('late daemon failure')) + await waitFor(() => + warn.mock.calls.some( + ([message, sessionId, error]) => + message === '[history] checkpoint failed:' && + sessionId === 'late-failure' && + error instanceof Error && + error.message === 'late daemon failure' + ) + ) + } finally { + adapterClass.PERIODIC_CHECKPOINT_DEADLINE_MS = previousDeadline + warn.mockRestore() + } + }) + describe('full-snapshot cooldown', () => { type CooldownInternals = { client: { request: ReturnType; disconnect: ReturnType } diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index e0397596f9f..3936a67cf97 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -162,9 +162,6 @@ const MAX_CONCURRENT_CHECKPOINTS = 4 // DAEMON_RESTORE_SCROLLBACK_ROWS through the headless emulator in well under a second, so this // only fires when the write path is genuinely wedged (STA-4173). const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000 -// Why the background pass gets a longer one: blowing it only defers a still-dirty session to the -// next tick, so the budget should sit above any slow-but-working disk rather than churn it. -const PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000 // Why far below the client's 30s default: a wedged daemon holds its socket open, so an unbounded // probe stalls a pane mount for the full request timeout — and the owner fan-out waits on every @@ -179,6 +176,40 @@ function remainingRequestTimeoutMs(deadlineMs: number | undefined): number | und return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now()) } +// Why a distinct error: teardown callers must read this as "not proven stopped" and leave the PTY +// alive, not as a kill that failed. It never matches isPtyAlreadyGoneError, so `stopAndWait` reports +// the pty unverified and worktree sleep declines to commit it. +export class FinalCheckpointWaitExpiredError extends Error { + constructor(sessionId: string) { + super(`Final history checkpoint did not settle within the teardown deadline: ${sessionId}`) + this.name = 'FinalCheckpointWaitExpiredError' + } +} + +// Why only the caller's wait is bounded and never the work itself: cancelling durable work would +// silently drop what the user left on screen. This decides how long a caller blocks, nothing more — +// `work` keeps running behind an abandoned wait and still commits. False means the caller gave up. +// A rejection before the deadline still propagates, so a genuinely failed operation is not masked. +async function awaitWithinCallerDeadline( + work: Promise, + deadlineMs: number +): Promise { + // Why up front: once the race is abandoned nothing observes a later rejection here. + void work.catch(() => {}) + let timer: ReturnType | undefined + try { + return await Promise.race([ + work.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now())) + timer.unref?.() + }) + ]) + } finally { + clearTimeout(timer) + } +} + export class TerminalKilledError extends Error { constructor(sessionId: string) { super(`Session "${sessionId}" was explicitly killed`) @@ -258,6 +289,11 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why per session: exclusivity protects one session directory's tmp-write/rename, so ordering // every session behind one tail let a single stalled checkpoint block all reattaches (STA-4173). private checkpointQueue = new CheckpointSessionQueue() + private nonFinalCheckpointAdmissionSessionIds = new Set() + private nonFinalAdmissionDeniedSessionIds = new Set() + private nonFinalGlobalAdmissionWarningActive = false + private overlayDeadlineWarnedSessionIds = new Set() + private periodicDeadlineWarnedSessionIds = new Set() private keepHistoryShutdowns = new Set>() private disconnectOnlyPromise: Promise | null = null // Why: checkpoint persistence needs the getSnapshot RPC (v4+); legacy daemons reject it, spamming logs every 5s. @@ -274,6 +310,9 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: a daemon surviving a socket drop can hold a pause whose resume died with the connection; owe a resume on reconnect (daemon's 5s failsafe covers the gap). private producerResumesOwedOnReconnect = new Set() private static CHECKPOINT_INTERVAL_MS = 5_000 + // Why the background pass gets a longer deadline: deferral keeps the session dirty, so this + // should sit above slow-but-working disk rather than churn it. + private static PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000 // Why: streaming sessions re-trigger full multi-MB checkpoints every tick; this cooldown caps cap/overflow snapshots per session (~9x less writes, bounded cold-crash staleness). private static FULL_CHECKPOINT_COOLDOWN_MS = 45_000 private lastFullCheckpointAt = new Map() @@ -1116,16 +1155,27 @@ export class DaemonPtyAdapter implements IPtyProvider { opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } ): Promise { // Why: shutdown can be the first lazy-client operation after restart; connect - // before killing so a healthy daemon session is not orphaned (#7742). Connect - // and kill share the caller's one absolute deadline, so a wedged handshake - // cannot burn the whole teardown budget before the kill even starts. + // before killing so a healthy daemon session is not orphaned (#7742). Connect, + // the final-checkpoint wait, and kill all share the caller's one absolute + // deadline, so neither a wedged handshake nor a stalled history write can burn + // the whole teardown budget before the kill even starts. Only the waits are + // bounded — the checkpoint itself stays deadline-free and lossless (STA-4228). await this.ensureConnected(opts.deadlineMs) // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. // Force a final snapshot so wake can restore the pane users left. if (opts.keepHistory) { - await this.runExclusiveCheckpoint(async () => { - await this.checkpointSessions([id], { final: true, teardown: true }) - }) + const committed = await this.runExclusiveCheckpoint( + async () => { + await this.checkpointSessions([id], { final: true, teardown: true }) + }, + { callerDeadlineMs: opts.deadlineMs } + ) + // Why throw instead of killing anyway: the snapshot the caller asked us to prove is still + // being written. Killing here would race the wake-time restore source to disk, so report the + // pty unverified and leave it alive — worktree sleep declines to commit it and retries. + if (!committed) { + throw new FinalCheckpointWaitExpiredError(id) + } const wslDistro = this.wslDistrosBySessionId.get(id) const detection = await this.historyReader?.detectColdRestoreState(id, { wslDistro }) const detected = detection?.status === 'restored' ? detection.restoreInfo : null @@ -1171,6 +1221,9 @@ export class DaemonPtyAdapter implements IPtyProvider { this.sessionsNeedingFullCheckpoint.delete(id) this.sessionsNeedingLiveCheckpoint.delete(id) this.sessionsNeedingContinuityCheckpoint.delete(id) + this.overlayDeadlineWarnedSessionIds.delete(id) + this.periodicDeadlineWarnedSessionIds.delete(id) + this.nonFinalAdmissionDeniedSessionIds.delete(id) this.lastFullCheckpointAt.delete(id) this.stopCheckpointTimerIfIdle() this.initialCwds.delete(id) @@ -1326,18 +1379,69 @@ export class DaemonPtyAdapter implements IPtyProvider { if (this.checkpointQueue.isSaturated(sessionId)) { return liveSnapshot } + // Why reserve before enqueueing: pane mounts can arrive in one turn, before any compact starts. + // Count abandoned waits until their writes settle so a relaunch cannot fan out unbounded work. + if (!this.tryAdmitNonFinalCheckpoint(sessionId)) { + return liveSnapshot + } // Why per session with a deadline: a reattach is a user click, so it must wait // on this session's own compact and nothing else. A blown deadline does not // cancel that compact — it keeps running and still commits — so the fallback // costs restore depth for this one reattach, never durable history (STA-4173). return await this.checkpointQueue.runWithDeadline( sessionId, - () => this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows), + async () => { + try { + return await this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows) + } finally { + this.releaseNonFinalCheckpointAdmission(sessionId) + this.overlayDeadlineWarnedSessionIds.delete(sessionId) + } + }, DURABLE_HISTORY_OVERLAY_DEADLINE_MS, - liveSnapshot + liveSnapshot, + { + onDeadline: () => { + if (!this.overlayDeadlineWarnedSessionIds.has(sessionId)) { + this.overlayDeadlineWarnedSessionIds.add(sessionId) + console.warn('[history] durable snapshot overlay deadline exceeded:', sessionId) + } + } + } ) } + private tryAdmitNonFinalCheckpoint(sessionId: string): boolean { + if (this.nonFinalCheckpointAdmissionSessionIds.has(sessionId)) { + if (!this.nonFinalAdmissionDeniedSessionIds.has(sessionId)) { + this.nonFinalAdmissionDeniedSessionIds.add(sessionId) + console.warn('[history] non-final checkpoint already in flight:', sessionId) + } + return false + } + if (this.nonFinalCheckpointAdmissionSessionIds.size >= MAX_CONCURRENT_CHECKPOINTS) { + this.reportNonFinalGlobalAdmissionDenial(sessionId) + return false + } + this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) + this.nonFinalCheckpointAdmissionSessionIds.add(sessionId) + return true + } + + private reportNonFinalGlobalAdmissionDenial(sessionId: string): void { + if (!this.nonFinalGlobalAdmissionWarningActive) { + this.nonFinalGlobalAdmissionWarningActive = true + console.warn('[history] non-final checkpoint global admission limit reached:', sessionId) + } + } + + private releaseNonFinalCheckpointAdmission(sessionId: string): void { + if (this.nonFinalCheckpointAdmissionSessionIds.delete(sessionId)) { + this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) + this.nonFinalGlobalAdmissionWarningActive = false + } + } + private async compactDurableRestoreSnapshot( sessionId: string, liveSnapshot: NonNullable, @@ -1671,6 +1775,9 @@ export class DaemonPtyAdapter implements IPtyProvider { this.sessionsNeedingFullCheckpoint.clear() this.sessionsNeedingLiveCheckpoint.clear() this.sessionsNeedingContinuityCheckpoint.clear() + this.overlayDeadlineWarnedSessionIds.clear() + this.periodicDeadlineWarnedSessionIds.clear() + this.nonFinalAdmissionDeniedSessionIds.clear() this.pausedProducerSessionIds.clear() this.producerResumesOwedOnReconnect.clear() this.stopCheckpointTimer() @@ -1781,6 +1888,9 @@ export class DaemonPtyAdapter implements IPtyProvider { this.sessionsNeedingFullCheckpoint.clear() this.sessionsNeedingLiveCheckpoint.clear() this.sessionsNeedingContinuityCheckpoint.clear() + this.overlayDeadlineWarnedSessionIds.clear() + this.periodicDeadlineWarnedSessionIds.clear() + this.nonFinalAdmissionDeniedSessionIds.clear() this.coldRestoreCache.clear() this.wslDistrosBySessionId.clear() this.pausedProducerSessionIds.clear() @@ -2072,25 +2182,43 @@ export class DaemonPtyAdapter implements IPtyProvider { this.stopCheckpointTimerIfIdle() } + /** False only when `callerDeadlineMs` expired first; the checkpoint itself keeps running. */ private async runExclusiveCheckpoint( operation: () => Promise, - options: { rescheduleDirty?: boolean } = {} - ): Promise { + options: { rescheduleDirty?: boolean; callerDeadlineMs?: number } = {} + ): Promise { this.stopCheckpointTimer() // Why: a promise tail keeps every waiter ordered; awaiting one active operation lets sibling waiters resume together. const previous = this.checkpointInFlight ?? Promise.resolve() const checkpoint = previous.catch(() => {}).then(operation) this.checkpointInFlight = checkpoint - try { - await checkpoint - } finally { - if (this.checkpointInFlight === checkpoint) { - this.checkpointInFlight = null - } - this.stopCheckpointTimer() - if (options.rescheduleDirty !== false) { - this.scheduleCheckpointTimer() + // Why the release rides the checkpoint instead of the caller's await: a caller that walks away + // at its deadline must leave this checkpoint as the tail, so the durable write still runs to + // completion, still commits, and the next waiter still queues behind it (STA-4228). + const settled = checkpoint.then( + () => this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty), + (err: unknown) => { + this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty) + throw err } + ) + if (options.callerDeadlineMs === undefined) { + await settled + return true + } + return await awaitWithinCallerDeadline(settled, options.callerDeadlineMs) + } + + private releaseExclusiveCheckpoint( + checkpoint: Promise, + rescheduleDirty: boolean | undefined + ): void { + if (this.checkpointInFlight === checkpoint) { + this.checkpointInFlight = null + } + this.stopCheckpointTimer() + if (rescheduleDirty !== false) { + this.scheduleCheckpointTimer() } } @@ -2116,6 +2244,17 @@ export class DaemonPtyAdapter implements IPtyProvider { const checkpointNext = async (): Promise => { for (;;) { + // No worker in this pass awaits abandoned work, so a full admission set cannot open here. + if ( + opts?.final !== true && + this.nonFinalCheckpointAdmissionSessionIds.size >= MAX_CONCURRENT_CHECKPOINTS + ) { + const deferredSessionId = ids[nextIndex] + if (deferredSessionId !== undefined) { + this.reportNonFinalGlobalAdmissionDenial(deferredSessionId) + } + return + } const index = nextIndex nextIndex++ if (index >= ids.length) { @@ -2158,22 +2297,45 @@ export class DaemonPtyAdapter implements IPtyProvider { sessionId: string, opts: { final: boolean; teardown: boolean } ): Promise<'done' | 'deferred'> { - const run = (): Promise<'done' | 'deferred'> => this.writeSessionCheckpoint(sessionId, opts) // Why final waits without a deadline: sleep/disconnect needs the last snapshot on disk, and // deferring there would silently drop what the user left on screen rather than delay it. if (opts.final) { - return await this.checkpointQueue.run(sessionId, run) + return await this.checkpointQueue.run(sessionId, () => + this.writeSessionCheckpoint(sessionId, opts) + ) } // Why 'deferred' is safe: the session stays dirty, so the operation that beat us to the queue // still commits and the next tick retries this one. Nothing on disk is discarded. - if (this.checkpointQueue.isSaturated(sessionId)) { + if ( + this.checkpointQueue.isSaturated(sessionId) || + !this.tryAdmitNonFinalCheckpoint(sessionId) + ) { return 'deferred' } + const run = async (): Promise<'done' | 'deferred'> => { + try { + return await this.writeSessionCheckpoint(sessionId, opts) + } finally { + this.releaseNonFinalCheckpointAdmission(sessionId) + this.periodicDeadlineWarnedSessionIds.delete(sessionId) + } + } return await this.checkpointQueue.runWithDeadline( sessionId, run, - PERIODIC_CHECKPOINT_DEADLINE_MS, - 'deferred' + DaemonPtyAdapter.PERIODIC_CHECKPOINT_DEADLINE_MS, + 'deferred', + { + onDeadline: () => { + if (!this.periodicDeadlineWarnedSessionIds.has(sessionId)) { + this.periodicDeadlineWarnedSessionIds.add(sessionId) + console.warn('[history] periodic checkpoint deadline exceeded:', sessionId) + } + }, + onAbandonedRejection: (error) => { + console.warn('[history] checkpoint failed:', sessionId, error) + } + } ) } @@ -2691,6 +2853,9 @@ export class DaemonPtyAdapter implements IPtyProvider { this.sessionsNeedingFullCheckpoint.delete(event.sessionId) this.sessionsNeedingLiveCheckpoint.delete(event.sessionId) this.sessionsNeedingContinuityCheckpoint.delete(event.sessionId) + this.overlayDeadlineWarnedSessionIds.delete(event.sessionId) + this.periodicDeadlineWarnedSessionIds.delete(event.sessionId) + this.nonFinalAdmissionDeniedSessionIds.delete(event.sessionId) // Why: a reused sessionId (renderer respawns a persisted ptyId) must not inherit the dead session's snapshot cooldown. this.lastFullCheckpointAt.delete(event.sessionId) this.stopCheckpointTimerIfIdle() diff --git a/src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts b/src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts index 87b896b207e..de39ae8fa81 100644 --- a/src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts +++ b/src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts @@ -154,20 +154,72 @@ describe('STA-4173 reattach isolation from a stalled checkpoint', () => { it('falls back to the live window when the session own checkpoint blows the deadline', async () => { const stalledId = await spawnWithOutput('deadline-session', 'DEADLINE_OUTPUT\r\n') const stall = await stallCheckpointFor(stalledId) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const reattach = await withinBudget( - adapter.spawn({ cols: 80, rows: 24, sessionId: stalledId, cwd: '/tmp' }), - DEADLINE_BUDGET_MS - ) + try { + const reattach = await withinBudget( + adapter.spawn({ cols: 80, rows: 24, sessionId: stalledId, cwd: '/tmp' }), + DEADLINE_BUDGET_MS + ) - expect(stall.entered()).toBeGreaterThan(0) - expect(reattach).not.toBe('timed-out') - expect(reattach).toMatchObject({ isReattach: true }) - // Degraded to the daemon's live window, not empty: the deadline costs restore depth, not history. - expect((reattach as { snapshot: string }).snapshot).toContain('DEADLINE_OUTPUT') - stall.release() + expect(stall.entered()).toBeGreaterThan(0) + expect(reattach).not.toBe('timed-out') + expect(reattach).toMatchObject({ isReattach: true }) + // Degraded to the daemon's live window, not empty: the deadline costs restore depth, not history. + expect((reattach as { snapshot: string }).snapshot).toContain('DEADLINE_OUTPUT') + expect(warn).toHaveBeenCalledWith( + '[history] durable snapshot overlay deadline exceeded:', + stalledId + ) + } finally { + stall.release() + warn.mockRestore() + } }, 30_000) + it('bounds overlay compacts across distinct sessions without blocking reattach', async () => { + const stalledIds = ['fanout-a', 'fanout-b', 'fanout-c', 'fanout-d'] + for (const sessionId of stalledIds) { + await spawnWithOutput(sessionId, `${sessionId.toUpperCase()}\r\n`) + } + const overflowId = await spawnWithOutput('fanout-overflow', 'FANOUT_OVERFLOW\r\n') + const manager = adapter.getHistoryManager()! + const original = manager.checkpoint.bind(manager) + const entered = new Set() + const checkpointed: string[] = [] + let release = (): void => {} + const stalled = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(manager, 'checkpoint').mockImplementation(async (sessionId, snapshot, opts) => { + checkpointed.push(sessionId) + if (stalledIds.includes(sessionId)) { + entered.add(sessionId) + await stalled + } + return await original(sessionId, snapshot, opts) + }) + + const reattaches = stalledIds.map((sessionId) => + adapter.spawn({ cols: 80, rows: 24, sessionId, cwd: '/tmp' }) + ) + try { + await vi.waitFor(() => expect(entered.size).toBe(stalledIds.length)) + + const overflowReattach = await withinBudget( + adapter.spawn({ cols: 80, rows: 24, sessionId: overflowId, cwd: '/tmp' }), + REATTACH_BUDGET_MS + ) + expect(overflowReattach).not.toBe('timed-out') + expect(overflowReattach).toMatchObject({ isReattach: true }) + expect((overflowReattach as { snapshot: string }).snapshot).toContain('FANOUT_OVERFLOW') + expect(checkpointed).not.toContain(overflowId) + } finally { + release() + await Promise.allSettled(reattaches) + } + }) + it('still commits the abandoned checkpoint once the history write completes', async () => { const stalledId = await spawnWithOutput('resumed-session', 'RESUMED_OUTPUT\r\n') const stall = await stallCheckpointFor(stalledId) diff --git a/src/main/daemon/daemon-restore-scrollback-depth.test.ts b/src/main/daemon/daemon-restore-scrollback-depth.test.ts index 3cc1d5d7ce2..a989ee0b603 100644 --- a/src/main/daemon/daemon-restore-scrollback-depth.test.ts +++ b/src/main/daemon/daemon-restore-scrollback-depth.test.ts @@ -486,6 +486,55 @@ describe('STA-4091 previously recoverable restore depth', () => { ) }) + // Assert durable depth, not the sequence that merely enables it. + it('preserves durable depth when an empty incremental take precedes a warm reattach', async () => { + const { id } = await adapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'empty-take-depth', + cwd: '/tmp' + }) + lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT)) + await adapter.getBufferSnapshot(id) + lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`) + + const oldInternals = adapter as unknown as { checkpointDirtySessions: () => Promise } + await oldInternals.checkpointDirtySessions() + const beforeReattach = await new HistoryReader(historyDir).detectColdRestore(id, { + ignoreCleanEnd: true + }) + expect(snapshotText(beforeReattach ?? {})).toContain(OLDEST_WRITTEN_LINE) + + // The trigger: a dirty mark with no new PTY records (the mock swallows the write, nothing echoes back). + adapter.write(id, 'noop') + await oldInternals.checkpointDirtySessions() + + simulateAdapterCrash(adapter) + adapter = new DaemonPtyAdapter({ + socketPath: getDaemonSocketPath(dir), + tokenPath: join(dir, 'test.token'), + historyPath: historyDir + }) + + const reattach = await adapter.spawn({ cols: 80, rows: 24, sessionId: id, cwd: '/tmp' }) + expect(reattach.snapshot).toContain(FRESH_AFTER_CHECKPOINT) + expect(reattach.snapshot).toContain(OLDEST_WRITTEN_LINE) + + // First post-reattach compact runs the continuity proof; it must not flatten the deep checkpoint. + const newInternals = adapter as unknown as { + checkpointDirtySessions: () => Promise + } + adapter.write(id, 'noop') + await newInternals.checkpointDirtySessions() + + const restored = await new HistoryReader(historyDir).detectColdRestore(id, { + ignoreCleanEnd: true + }) + expect(snapshotText(restored ?? {})).toContain(OLDEST_WRITTEN_LINE) + expect(snapshotText(restored ?? {})).toContain(PREVIOUSLY_RECOVERABLE_LINE) + expect(restored?.scrollbackLines).toBeGreaterThan(DAEMON_SESSION_SCROLLBACK_ROWS) + }) + it('falls back to the live window when durable history cannot be read', async () => { const { id } = await adapter.spawn({ cols: 80, diff --git a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts new file mode 100644 index 00000000000..679c124e2d6 --- /dev/null +++ b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DaemonPtyAdapter, FinalCheckpointWaitExpiredError } from './daemon-pty-adapter' +import { DaemonServer } from './daemon-server' +import { getDaemonSocketPath } from './daemon-spawner' +import type { DaemonFileLog } from './daemon-file-log' +import { HistoryReader } from './history-reader' +import type { HistoryCheckpointResult } from './terminal-history-manager-options' +import type { SubprocessHandle } from './session' +import type { TerminalSnapshot } from './types' + +// Worktree sleep threads an absolute deadline into stopAndWait; this stands in for it. +const CALLER_DEADLINE_MS = 300 +// Why well above the deadline it proves: a stop that only settles because the whole suite is slow +// would prove nothing, and a stop that never settles must fail this test rather than hang the run. +const STOP_BUDGET_MS = 8_000 + +function createMockSubprocess(): SubprocessHandle & { emitData: (data: string) => void } { + let onData: ((data: string) => void) | undefined + let onExit: ((code: number) => void) | undefined + return { + pid: 4243, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => setTimeout(() => onExit?.(0), 1)), + forceKill: vi.fn(() => onExit?.(137)), + signal: vi.fn(), + onData(callback) { + onData = callback + }, + onExit(callback) { + onExit = callback + }, + dispose: vi.fn(), + emitData(data) { + onData?.(data) + } + } +} + +/** Resolves 'timed-out' instead of hanging, so a stranded stop fails the test rather than the run. */ +async function withinBudget(work: Promise, budgetMs: number): Promise { + let timer: ReturnType | undefined + const budget = new Promise<'timed-out'>((resolve) => { + timer = setTimeout(() => resolve('timed-out'), budgetMs) + }) + try { + return await Promise.race([work, budget]) + } finally { + clearTimeout(timer) + } +} + +async function rejectionOf(work: Promise): Promise { + try { + await work + } catch (error) { + return error + } + throw new Error('Expected work to reject') +} + +describe('STA-4228 keep-history stop bounds only the caller wait on the final checkpoint', () => { + let dir: string + let server: DaemonServer + let adapter: DaemonPtyAdapter + let subprocesses: ReturnType[] + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'orca-final-checkpoint-deadline-')) + subprocesses = [] + const log: DaemonFileLog = { log: () => {}, close: () => {} } + server = new DaemonServer({ + socketPath: getDaemonSocketPath(dir), + tokenPath: join(dir, 'test.token'), + log, + spawnSubprocess: () => { + const subprocess = createMockSubprocess() + subprocesses.push(subprocess) + return subprocess + } + }) + await server.start() + adapter = new DaemonPtyAdapter({ + socketPath: getDaemonSocketPath(dir), + tokenPath: join(dir, 'test.token'), + historyPath: join(dir, 'history') + }) + }) + + afterEach(async () => { + adapter?.dispose() + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + /** Wedges one session's checkpoint at the history-write layer until explicitly released. */ + function stallCheckpointFor(stalledSessionId: string): { release: () => void } { + const manager = adapter.getHistoryManager() + expect(manager).not.toBeNull() + const original = manager!.checkpoint.bind(manager!) + let release = (): void => {} + const stalled = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(manager!, 'checkpoint').mockImplementation( + async ( + sessionId: string, + snapshot: TerminalSnapshot, + opts?: { pendingOutputSeq?: number } + ): Promise => { + if (sessionId !== stalledSessionId) { + return await original(sessionId, snapshot, opts) + } + await stalled + return await original(sessionId, snapshot, opts) + } + ) + return { release } + } + + async function spawnWithOutput(sessionId: string, output: string): Promise { + const { id } = await adapter.spawn({ cols: 80, rows: 24, sessionId, cwd: '/tmp' }) + subprocesses.at(-1)!.emitData(output) + return id + } + + function stopKeepingHistory(id: string): Promise { + return adapter.shutdown(id, { + immediate: true, + keepHistory: true, + deadlineMs: Date.now() + CALLER_DEADLINE_MS + }) + } + + async function onDisk(sessionId: string): Promise { + const restore = await new HistoryReader(join(dir, 'history')).detectColdRestore(sessionId, { + ignoreCleanEnd: true + }) + return `${restore?.scrollbackAnsi ?? ''}${restore?.snapshotAnsi ?? ''}` + } + + it('lets a later keep-history stop settle while an earlier final checkpoint is wedged', async () => { + const wedgedId = await spawnWithOutput('wedged-session', 'WEDGED_OUTPUT\r\n') + const laterId = await spawnWithOutput('later-session', 'LATER_OUTPUT\r\n') + const stall = stallCheckpointFor(wedgedId) + + expect( + await withinBudget(rejectionOf(stopKeepingHistory(wedgedId)), STOP_BUDGET_MS) + ).toBeInstanceOf(FinalCheckpointWaitExpiredError) + // Why issued only now: this is the sleep-stranding case — a stop that starts while the wedged + // checkpoint still owns the exclusive tail, which before this fix waited on it forever. + expect( + await withinBudget(rejectionOf(stopKeepingHistory(laterId)), STOP_BUDGET_MS) + ).toBeInstanceOf(FinalCheckpointWaitExpiredError) + stall.release() + }, 30_000) + + it('leaves the unverified pty alive instead of falling through to the kill', async () => { + const wedgedId = await spawnWithOutput('alive-session', 'ALIVE_OUTPUT\r\n') + const stall = stallCheckpointFor(wedgedId) + + expect( + await withinBudget(rejectionOf(stopKeepingHistory(wedgedId)), STOP_BUDGET_MS) + ).toBeInstanceOf(FinalCheckpointWaitExpiredError) + + // Why liveness and not just bookkeeping: the whole point is that an unproven snapshot must not + // authorize the kill, so the daemon must still answer for this session. + expect(await adapter.probePtyLiveness(wedgedId)).toBe(true) + expect(adapter.hasPty(wedgedId)).toBe(true) + stall.release() + }, 30_000) + + it('still commits every abandoned final checkpoint once the wedged history write completes', async () => { + const wedgedId = await spawnWithOutput('committing-session', 'COMMITTING_OUTPUT\r\n') + const laterId = await spawnWithOutput('queued-session', 'QUEUED_OUTPUT\r\n') + const stall = stallCheckpointFor(wedgedId) + + expect( + await withinBudget(rejectionOf(stopKeepingHistory(wedgedId)), STOP_BUDGET_MS) + ).toBeInstanceOf(FinalCheckpointWaitExpiredError) + expect( + await withinBudget(rejectionOf(stopKeepingHistory(laterId)), STOP_BUDGET_MS) + ).toBeInstanceOf(FinalCheckpointWaitExpiredError) + + stall.release() + // Why this is the whole point of bounding the wait and not the work: both callers walked away, + // and both durable writes still land on disk — the abandoned one and the one queued behind it. + await vi.waitFor(async () => { + expect(await onDisk(wedgedId)).toContain('COMMITTING_OUTPUT') + expect(await onDisk(laterId)).toContain('QUEUED_OUTPUT') + }) + }, 30_000) + + it('resumes periodic durable writes after a rejected exclusive checkpoint', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } + const internals = adapter as unknown as { + runExclusiveCheckpoint(operation: () => Promise): Promise + } + const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + const rejection = new Error('injected checkpoint rejection') + + try { + await expect( + internals.runExclusiveCheckpoint(async () => { + throw rejection + }) + ).rejects.toBe(rejection) + + adapterClass.CHECKPOINT_INTERVAL_MS = 10 + const id = await spawnWithOutput('post-rejection-session', 'POST_REJECTION_OUTPUT\r\n') + await vi.waitFor(async () => { + expect(await onDisk(id)).toContain('POST_REJECTION_OUTPUT') + }) + } finally { + adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + } + }) +}) diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index bf618a29d45..48517bb6653 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -248,7 +248,7 @@ export class HistoryManager { try { // Why: tmp+rename is atomic (corrupt checkpoint > stale); async so a sync ~MB write can't stall IPC (worse under Windows AV). - // The adapter's checkpointInFlight guard serializes checkpoints, so concurrent async writes can't collide on the fixed .tmp path. + // The adapter's per-session checkpoint queue prevents concurrent writes from colliding on the fixed .tmp path. const checkpoint = await writer.checkpoint(snapshot, opts) if (checkpoint.result === 'retryable') { this.onWriteError?.(sessionId, checkpoint.error) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 81d77da5720..c64df7676f3 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -39,6 +39,7 @@ vi.mock('../pwsh', () => ({ const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' +const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe' vi.mock('../providers/windows-powershell-executable', () => ({ resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, @@ -2457,7 +2458,8 @@ describe('createPtySubprocess', () => { cols: 80, rows: 24, shellOverride: 'cmd.exe', - terminalWindowsPowerShellImplementation: 'pwsh.exe' + terminalWindowsPowerShellImplementation: 'pwsh.exe', + env: { ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT } }) } finally { if (platform) { @@ -2467,8 +2469,13 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'cmd.exe', - ['/K', 'chcp 65001 > nul'], - expect.any(Object) + [ + '/K', + 'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1' + ], + expect.objectContaining({ + env: expect.objectContaining({ ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE: '"' }) + }) ) }) @@ -2517,7 +2524,8 @@ describe('createPtySubprocess', () => { rows: 24, cwd: 'C:\\repo\\orca', shellOverride: 'cmd.exe', - command: `codex ${'x'.repeat(7000)}` + command: `codex ${'x'.repeat(7000)}`, + env: { ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT } }) } finally { if (platform) { @@ -2527,7 +2535,10 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'cmd.exe', - ['/K', 'chcp 65001 > nul'], + [ + '/K', + 'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1' + ], expect.any(Object) ) expect(handle!.startupCommandDeliveredInShellArgs).toBeUndefined() @@ -2546,7 +2557,8 @@ describe('createPtySubprocess', () => { cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo', - shellOverride: 'C:\\PortableGit\\bin\\bash.exe' + shellOverride: 'C:\\PortableGit\\bin\\bash.exe', + env: { ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT } }) } finally { if (platform) { @@ -2556,10 +2568,18 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\PortableGit\\bin\\bash.exe', - ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], + [ + '-c', + expect.stringMatching( + /^chcp\.com 65001 >\/dev\/null 2>&1; exec "\$BASH" --rcfile '.*shell-ready\/bash\/rcfile' -i$/ + ) + ], expect.objectContaining({ cwd: 'C:\\Users\\jin\\repo', - env: expect.objectContaining({ CHERE_INVOKING: '1' }) + env: expect.objectContaining({ + CHERE_INVOKING: '1', + ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT + }) }) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index d529c883fee..553889189c4 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -20,7 +20,10 @@ import { import { wrapShellSpawnForMacosTccAttribution } from '../providers/macos-tcc-login-shell' import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group' import { readPtsName } from '../pty/node-pty-pts-name' -import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args' +import { + ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, + resolveWindowsShellLaunchArgs +} from '../providers/windows-shell-args' import { resolveEffectiveWindowsPowerShell, shouldProbeWindowsPowerShellAvailability, @@ -693,6 +696,13 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl }) ?? shellPath) : shellPath } + if ( + pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' && + env.ORCA_CODEX_LAUNCH_PREFLIGHT + ) { + // Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead. + env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' + } // Why: a bare `pwsh.exe` resolves to the Store App Execution Alias stub whose launch fails with ERROR_ACCESS_DENIED (5). windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ shellPath, @@ -714,7 +724,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl spawnCwd, getDefaultCwd(), resolvedWslContext, - opts.command + opts.command, + env.ORCA_CODEX_LAUNCH_PREFLIGHT ) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd @@ -745,7 +756,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl { distro: codexHomeWslInfo.distro }, - opts.command + opts.command, + env.ORCA_CODEX_LAUNCH_PREFLIGHT ) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 84eb7fef15d..f25a6fc5377 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -494,7 +494,10 @@ export class Session { this.pendingOutputRecords = [] this.pendingOutputBytes = 0 this.pendingOutputOverflowed = false - this.pendingOutputSeq += 1 + // Empty incremental takes are not persisted; advancing them would create a false reattach gap. + if (includeSnapshot || records.length > 0 || overflowed) { + this.pendingOutputSeq += 1 + } return { records: includeSnapshot ? releasedHeldBytes diff --git a/src/main/daemon/terminal-snapshot-color-parity.test.ts b/src/main/daemon/terminal-snapshot-color-parity.test.ts index 576a20db431..bf52c9d651c 100644 --- a/src/main/daemon/terminal-snapshot-color-parity.test.ts +++ b/src/main/daemon/terminal-snapshot-color-parity.test.ts @@ -21,7 +21,7 @@ async function restoreSerializedTerminal(source: ParityTerminal): Promise JSON.stringify round trip would + * silently drop all of them. + */ +export function serializeDevinHooksConfig( + originalText: string | null, + nextConfig: HooksConfig +): string { + if (originalText === null) { + return `${JSON.stringify(nextConfig, null, 2)}\n` + } + + const previous = parseJsonc(originalText) as HooksConfig | undefined + const previousHooks = isPlainObject(previous?.hooks) ? (previous.hooks ?? {}) : {} + const nextHooks = nextConfig.hooks ?? {} + + let text = originalText + // Why: touch only the events that actually changed, so comments attached to a user's + // own untouched hook entries stay put. + for (const eventName of new Set([...Object.keys(previousHooks), ...Object.keys(nextHooks)])) { + const nextValue = nextHooks[eventName] + if (JSON.stringify(previousHooks[eventName]) === JSON.stringify(nextValue)) { + continue + } + text = applyEdits( + text, + // Why: `undefined` removes the key, which is how remove() drops an emptied event. + modify(text, ['hooks', eventName], nextValue, { + formattingOptions: { insertSpaces: true, tabSize: 2 } + }) + ) + } + return text +} + export function parseDevinHooksConfigText( text: string, diagnosticName: string diff --git a/src/main/devin/hook-service.test.ts b/src/main/devin/hook-service.test.ts index 98a83993809..e606d54a19f 100644 --- a/src/main/devin/hook-service.test.ts +++ b/src/main/devin/hook-service.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { parse as parseJsonc } from 'jsonc-parser' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -104,7 +105,45 @@ describe('DevinHookService', () => { const status = new DevinHookService().install() expect(status.state).toBe('installed') - expect(JSON.parse(readFileSync(configPath, 'utf8')).hooks.UserPromptSubmit).toBeDefined() + // Why: parse as JSONC, not JSON — asserting with JSON.parse would only pass once the + // comment had been stripped, which is the regression this test exists to catch. + expect(parseJsonc(readFileSync(configPath, 'utf8')).hooks.UserPromptSubmit).toBeDefined() + }) + + it('keeps user comments and unrelated formatting when installing and removing', () => { + const configPath = getDevinConfigPath() + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `{ + // keep me: chosen deliberately + "permissions": { "mode": "normal" }, + /* block comment */ + "hooks": { + // the user's own hook + "SessionEnd": [{ "hooks": [{ "type": "command", "command": "mine.sh" }] }] + } +} +` + ) + + const service = new DevinHookService() + service.install() + + const installed = readFileSync(configPath, 'utf8') + expect(installed).toContain('// keep me: chosen deliberately') + expect(installed).toContain('/* block comment */') + expect(installed).toContain("// the user's own hook") + expect(installed).toContain('mine.sh') + expect(parseJsonc(installed).permissions.mode).toBe('normal') + expect(parseJsonc(installed).hooks.UserPromptSubmit).toBeDefined() + + service.remove() + + const removed = readFileSync(configPath, 'utf8') + expect(removed).toContain('// keep me: chosen deliberately') + expect(removed).toContain('mine.sh') + expect(parseJsonc(removed).hooks.UserPromptSubmit).toBeUndefined() }) it('surfaces read_config_from overlap in status detail', () => { diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index 6cff0d2b824..6c485b0daf5 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -32,7 +32,9 @@ import { mergeHookInstallDetail, parseDevinHooksConfigText, readConfigFromOrcaOverlapDetail, - readDevinHooksConfig + readDevinHooksConfig, + readDevinHooksSource, + serializeDevinHooksConfig } from './hook-config-json' function getManagedScript(target: 'local' | 'posix' = 'local'): string { @@ -140,8 +142,8 @@ export class DevinHookService { install(): AgentHookInstallStatus { const configPath = getDevinConfigPath() const scriptPath = getDevinManagedScriptPath() - const config = readDevinHooksConfig(configPath) - if (!config) { + const source = readDevinHooksSource(configPath) + if (!source) { return { agent: 'devin', state: 'error', @@ -152,9 +154,15 @@ export class DevinHookService { } const command = getDevinManagedCommand(scriptPath) - const nextConfig = applyDevinManagedHooks(config, command, getDevinManagedScriptFileName()) + const nextConfig = applyDevinManagedHooks( + source.config, + command, + getDevinManagedScriptFileName() + ) writeManagedScript(scriptPath, getManagedScript()) - writeHooksJson(configPath, nextConfig) + writeHooksJson(configPath, nextConfig, { + serialized: serializeDevinHooksConfig(source.text, nextConfig) + }) return this.getStatus() } @@ -187,7 +195,9 @@ export class DevinHookService { // Why: write script before settings so a mid-install failure never leaves settings.json referencing a missing script. // Why: SSH remotes use POSIX `.sh` hooks even when Orca runs on Windows; never derive remote script syntax from local OS. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) - await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) + await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig, { + serialized: serializeDevinHooksConfig(body, nextConfig) + }) return { agent: 'devin', @@ -209,8 +219,8 @@ export class DevinHookService { remove(): AgentHookInstallStatus { const configPath = getDevinConfigPath() - const config = readDevinHooksConfig(configPath) - if (!config) { + const source = readDevinHooksSource(configPath) + if (!source) { return { agent: 'devin', state: 'error', @@ -220,11 +230,13 @@ export class DevinHookService { } } const { config: nextConfig, changed } = removeDevinManagedHooks( - config, + source.config, getDevinManagedScriptFileName() ) if (changed) { - writeHooksJson(configPath, nextConfig) + writeHooksJson(configPath, nextConfig, { + serialized: serializeDevinHooksConfig(source.text, nextConfig) + }) } return this.getStatus() } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 5c1516679d1..21eecad6fd5 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -17,9 +17,13 @@ import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-h import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' import type * as Wsl from '../wsl' +import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it +const BUNDLED_RESOURCES_PATH = join('/tmp', 'orca-bundled-resources') +// Why: this suite forces darwin before every test, including on Linux CI. +const BUNDLED_CLI_PATH = getBundledLauncherPath('darwin', BUNDLED_RESOURCES_PATH) as string // Why: bare shells no longer mkdir ~/.omp; OMP status lives under userData (#10196). const expectedOmpStatusExtension = posix.join( '/tmp/orca-user-data', @@ -133,7 +137,8 @@ vi.mock('fs', () => ({ writeFileSync: writeFileSyncMock, chmodSync: chmodSyncMock, constants: { - X_OK: 1 + X_OK: 1, + R_OK: 4 } })) @@ -1939,6 +1944,42 @@ describe('registerPtyHandlers', () => { ] } + // Why: the Codex launch preflight now carries the bundled CLI's verified absolute + // path, so these cases need a resources root whose launcher passes the exec check. + async function withBundledCli( + run: () => Promise, + options?: { launcherExecutable?: boolean } + ): Promise { + const launcherExecutable = options?.launcherExecutable ?? true + const previousResourcesPath = process.resourcesPath + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: BUNDLED_RESOURCES_PATH + }) + // Why: only teach the launcher path to look like an executable file; every other + // stat/access keeps the permissive default the rest of the harness relies on. + statSyncMock.mockImplementation((target: string) => ({ + isDirectory: () => target !== BUNDLED_CLI_PATH, + isFile: () => target === BUNDLED_CLI_PATH, + mode: 0o755 + })) + if (!launcherExecutable) { + accessSyncMock.mockImplementation((target: string) => { + if (target === BUNDLED_CLI_PATH) { + throw new Error(`EACCES: ${target}`) + } + }) + } + try { + return await run() + } finally { + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: previousResourcesPath + }) + } + } + describe('spawn environment', () => { it('refreshes the outer Windows PATH for a WSL spawn without forwarding it', async () => { const originalPlatform = process.platform @@ -2133,10 +2174,40 @@ describe('registerPtyHandlers', () => { }) it('injects the selected Codex home into Orca terminal PTYs', async () => { - const env = await spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME) + const env = await withBundledCli(() => + spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME) + ) expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME) expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) - expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe('orca') + // Why (STA-4270): a bare name would be resolved by the post-profile PATH the codex() + // wrapper inherits, so the preflight must carry the CLI's verified absolute path. + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(BUNDLED_CLI_PATH) + }) + + it('skips the Codex launch preflight when the bundled CLI is not executable', async () => { + const env = await withBundledCli( + () => spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME), + { launcherExecutable: false } + ) + + expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME) + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined() + }) + + // Why (STA-4270): profile scripts run before the codex() wrapper and routinely prepend + // directories to PATH, so a scratch `orca` there must never become the preflight. + it('pins the Codex launch preflight to the bundled CLI even when PATH leads elsewhere', async () => { + const env = await withBundledCli(() => + spawnAndGetEnv( + { PATH: `/tmp/hijack-scratch${delimiter}/usr/bin` }, + undefined, + () => TEST_CODEX_HOME + ) + ) + + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(BUNDLED_CLI_PATH) + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).not.toBe('orca') + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT.startsWith('/tmp/hijack-scratch')).toBe(false) }) it('does not install the Codex launch preflight when Codex hooks are disabled', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index bf2d10f5125..767c9eb7af2 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1869,7 +1869,9 @@ export function buildPtyHostEnv( hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled, isPackaged: opts.isPackaged, isWsl: opts.isWsl, - managedHomePath: opts.selectedCodexHomePath + managedHomePath: opts.selectedCodexHomePath, + userDataPath: opts.userDataPath, + resourcesPath: opts.resourcesPath }) if (preflightCommand) { baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 43277f5ac75..954f96d7565 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -1185,14 +1185,12 @@ describe('repos:addRemote', () => { kind: 'git', displayName: 'project', badgeColor: DEFAULT_REPO_BADGE_COLOR, - externalWorktreeVisibility: 'hide', - externalWorktreeVisibilityLegacy: false, projectHostSetupMethod: 'imported-existing-folder' }) ) expect(result).toHaveProperty('repo.id') expect(result).toHaveProperty('repo.connectionId', 'conn-1') - expect(result).toHaveProperty('repo.externalWorktreeVisibility', 'hide') + expect(result).not.toHaveProperty('repo.externalWorktreeVisibility') }) it('uses custom displayName when provided', async () => { @@ -1234,9 +1232,7 @@ describe('repos:addRemote', () => { connectionId: 'conn-1', kind: 'git', displayName: 'orca', - badgeColor: DEFAULT_REPO_BADGE_COLOR, - externalWorktreeVisibility: 'hide', - externalWorktreeVisibilityLegacy: false + badgeColor: DEFAULT_REPO_BADGE_COLOR }) ) expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', { @@ -1462,8 +1458,7 @@ describe('repos:addRemote', () => { path: '/home/user/created', connectionId: 'conn-1', kind: 'git', - displayName: 'created', - externalWorktreeVisibility: 'hide' + displayName: 'created' }) ) expect(result).toHaveProperty('repo.path', '/home/user/created') @@ -1879,19 +1874,19 @@ describe('repos:add + repos:clone', () => { expect(result).toHaveProperty('repo.badgeColor', DEFAULT_REPO_BADGE_COLOR) }) - it('defaults new git repos:add records to hiding non-Orca worktrees', async () => { + it('inherits global non-Orca visibility while retaining the mixed-version safety marker', async () => { const result = await handlers.get('repos:add')!(null, { path: '/tmp/from-add', kind: 'git' }) expect(mockStore.addRepo).toHaveBeenCalledWith( expect.objectContaining({ path: '/tmp/from-add', kind: 'git', - externalWorktreeVisibility: 'hide', externalWorktreeVisibilityLegacy: false, projectHostSetupMethod: 'imported-existing-folder' }) ) - expect(result).toHaveProperty('repo.externalWorktreeVisibility', 'hide') + expect(result).not.toHaveProperty('repo.externalWorktreeVisibility') + expect(result).toHaveProperty('repo.externalWorktreeVisibilityLegacy', false) }) it('prepares the worktree root when adding a local git repo', async () => { @@ -2280,13 +2275,11 @@ describe('repos:add + repos:clone', () => { expect.objectContaining({ path: join(destination, 'orca'), badgeColor: DEFAULT_REPO_BADGE_COLOR, - kind: 'git', - externalWorktreeVisibility: 'hide', - externalWorktreeVisibilityLegacy: false + kind: 'git' }) ) expect(result).toHaveProperty('badgeColor', DEFAULT_REPO_BADGE_COLOR) - expect(result).toHaveProperty('externalWorktreeVisibility', 'hide') + expect(result).not.toHaveProperty('externalWorktreeVisibility') }) it('drops a same-path negative submodule cache before a local clone', async () => { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index edb9cded4da..b16bc788b91 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -356,7 +356,6 @@ async function addLocalRepoFromPath( kind: repoKind, ...(repoKind === 'git' ? { - externalWorktreeVisibility: 'hide' as const, externalWorktreeVisibilityLegacy: false, // Why: new Add Project imports are explicit ready host setups; 'legacy-repo' is reserved for older records/projection. projectHostSetupMethod: 'imported-existing-folder' as const @@ -455,7 +454,6 @@ async function addRemoteRepoFromPath( connectionId: args.connectionId, ...(repoKind === 'git' ? { - externalWorktreeVisibility: 'hide' as const, externalWorktreeVisibilityLegacy: false, projectHostSetupMethod: args.setupMethod ?? ('imported-existing-folder' as const) } @@ -1774,7 +1772,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v addedAt: Date.now(), kind: 'git', ...(args.connectionId ? { connectionId: args.connectionId } : {}), - externalWorktreeVisibility: 'hide', externalWorktreeVisibilityLegacy: false, projectHostSetupMethod: 'imported-existing-folder', ...(group @@ -2034,7 +2031,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v kind: repoKind, ...(repoKind === 'git' ? { - externalWorktreeVisibility: 'hide' as const, externalWorktreeVisibilityLegacy: false, projectHostSetupMethod: 'imported-existing-folder' as const } @@ -2126,17 +2122,17 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v | 'symlinkPaths' | 'issueSourcePreference' | 'forkSyncMode' - | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' - | 'agentWorktreeVisibility' | 'customWorktreeVisibilitySources' | 'worktreeVisibilitySourcePreferences' | 'projectGroupId' | 'projectGroupOrder' > > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: | Repo['externalWorktreeDiscoverySuppressedAt'] @@ -2195,7 +2191,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v updates.badgeColor = badgeColor } } - if ( + if ('externalWorktreeVisibility' in updates && updates.externalWorktreeVisibility === null) { + updates.externalWorktreeVisibility = undefined + } else if ( 'externalWorktreeVisibility' in updates && updates.externalWorktreeVisibility !== undefined && updates.externalWorktreeVisibility !== 'hide' && @@ -2205,6 +2203,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } if ( 'agentWorktreeVisibility' in updates && + updates.agentWorktreeVisibility !== null && updates.agentWorktreeVisibility !== undefined && updates.agentWorktreeVisibility !== 'hide' && updates.agentWorktreeVisibility !== 'show' @@ -2551,7 +2550,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ...detected, addedAt: Date.now(), kind: 'git', - externalWorktreeVisibility: 'hide', externalWorktreeVisibilityLegacy: false, projectHostSetupMethod: 'cloned' } diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index fcf52816dab..ba8012bab04 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -49,13 +49,51 @@ describe('registerRuntimeHandlers', () => { fromWebContentsMock.mockReturnValue({ id: 17 }) + const currentMainFrame = {} + const sender = { mainFrame: currentMainFrame } const handler = syncRegistration![1] - const result = handler({ sender: {} }, { tabs: [], leaves: [] }) + const graph = { tabs: [], leaves: [], rendererGeneration: 'renderer-1' } + const result = handler({ sender, senderFrame: currentMainFrame }, graph) - expect(runtime.syncWindowGraph).toHaveBeenCalledWith(17, { tabs: [], leaves: [] }) + expect(runtime.syncWindowGraph).toHaveBeenCalledWith(17, graph) expect(result).toEqual({ graphStatus: 'ready' }) }) + it('rejects a graph publication queued by a superseded main frame', () => { + const runtime = { + syncWindowGraph: vi.fn(), + getStatus: vi.fn(), + getRuntimeId: vi.fn() + } + registerRuntimeHandlers(runtime as never) + const handler = handleMock.mock.calls.find( + ([channel]) => channel === 'runtime:syncWindowGraph' + )![1] + const sender = { mainFrame: { generation: 2 } } + fromWebContentsMock.mockReturnValue({ id: 17 }) + + expect(() => + handler({ sender, senderFrame: { generation: 1 } }, { tabs: [], leaves: [] }) + ).toThrow('Runtime graph sync must originate from the current main frame') + expect(runtime.syncWindowGraph).not.toHaveBeenCalled() + }) + + it('rejects graph publications without a renderer generation', () => { + const runtime = { syncWindowGraph: vi.fn() } + registerRuntimeHandlers(runtime as never) + const handler = handleMock.mock.calls.find( + ([channel]) => channel === 'runtime:syncWindowGraph' + )![1] + const currentMainFrame = {} + const sender = { mainFrame: currentMainFrame } + fromWebContentsMock.mockReturnValue({ id: 17 }) + + expect(() => + handler({ sender, senderFrame: currentMainFrame }, { tabs: [], leaves: [] }) + ).toThrow('Runtime graph sync requires a renderer generation') + expect(runtime.syncWindowGraph).not.toHaveBeenCalled() + }) + it('routes generic local runtime RPC calls through the dispatcher', async () => { const runtime = { syncWindowGraph: vi.fn(), diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index dc1ff4ff4e4..e97b96cd877 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -2,9 +2,9 @@ import { BrowserWindow, ipcMain } from 'electron' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RuntimeBrowserDriverState, + RuntimeRendererSyncWindowGraph, RuntimeStatus, RuntimeSyncWindowGraphResult, - RuntimeSyncWindowGraph, RuntimeTerminalDriverState } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' @@ -33,11 +33,19 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { ipcMain.handle( 'runtime:syncWindowGraph', - (event, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult => { + (event, graph: RuntimeRendererSyncWindowGraph): RuntimeSyncWindowGraphResult => { const window = BrowserWindow.fromWebContents(event.sender) if (!window) { throw new Error('Runtime graph sync must originate from a BrowserWindow') } + if (event.senderFrame !== event.sender.mainFrame) { + // Why: a disposed main frame can leave an invoke queued after its + // replacement starts. It must not settle the replacement generation. + throw new Error('Runtime graph sync must originate from the current main frame') + } + if (typeof graph.rendererGeneration !== 'string' || graph.rendererGeneration.length === 0) { + throw new Error('Runtime graph sync requires a renderer generation') + } return runtime.syncWindowGraph(window.id, graph) } ) diff --git a/src/main/ipc/workspace-cleanup-activity.test.ts b/src/main/ipc/workspace-cleanup-activity.test.ts index a7d1e980266..a0b68ccb683 100644 --- a/src/main/ipc/workspace-cleanup-activity.test.ts +++ b/src/main/ipc/workspace-cleanup-activity.test.ts @@ -1,3 +1,5 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' @@ -135,10 +137,40 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { readTextFile ) - expect(readTextFile).toHaveBeenCalledWith(reflogPath) + // Why: only the tail of the reflog is read — the newest entry is enough. + expect(readTextFile).toHaveBeenCalledWith(reflogPath, { tailBytes: 8192 }) expect(worktree.lastActivityAt).toBe(1_700_000_900_000) }) + it('falls back to a full reflog read when the newest entry exceeds the tail window', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'cleanup-activity-')) + try { + const gitDir = path.join(dir, 'gitdir') + await mkdir(path.join(gitDir, 'logs'), { recursive: true }) + const worktreePath = path.join(dir, 'wt') + await mkdir(worktreePath, { recursive: true }) + await writeFile(path.join(worktreePath, '.git'), `gitdir: ${gitDir}\n`) + // Why: a single record longer than the 8192-byte tail window keeps its + // timestamp before the window; the reader must fall back to a full read. + await writeFile( + path.join(gitDir, 'logs', 'HEAD'), + '0000 1111 Dev 1700000000 -0700\tbranch: Created from HEAD\n' + + `1111 2222 Dev 1700000900 -0700\tcommit: ${'x'.repeat(9000)}\n` + ) + const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree({ path: worktreePath }), + statPath + ) + + expect(worktree.lastActivityAt).toBe(1_700_000_900_000) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + it('degrades to other probes when the reflog was expired to an empty file', async () => { const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) diff --git a/src/main/ipc/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup-activity.ts index 93a6b5673cd..0d7c44b602c 100644 --- a/src/main/ipc/workspace-cleanup-activity.ts +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -1,4 +1,4 @@ -import { lstat, readFile } from 'node:fs/promises' +import { lstat, open, readFile } from 'node:fs/promises' import path from 'node:path' import type { Repo } from '../../shared/repo-types' import type { Worktree } from '../../shared/worktree/types' @@ -7,7 +7,11 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }> -type ReadTextFile = (targetPath: string) => Promise +type ReadTextFile = (targetPath: string, options?: { tailBytes?: number }) => Promise + +// Why: only the newest reflog entry matters; a long-lived main worktree's full +// reflog can be megabytes, and it was read and split whole per broad scan. +const REFLOG_TAIL_BYTES = 8192 export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Worktree): Worktree { const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) @@ -17,13 +21,24 @@ export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Workt return { ...worktree, lastActivityAt: persistedActivityAt } } +export type WorkspaceCleanupFsActivityCache = Map> + export async function resolveWorkspaceCleanupActivityWorktree( repo: Repo, worktree: Worktree, statPath: StatPath = statLocalPath, - readTextFile: ReadTextFile = readLocalTextFile + readTextFile: ReadTextFile = readLocalTextFile, + // Why: folder workspaces surface one row per instance of the same directory; + // the filesystem probes are identical per path and must run once per scan. + fsActivityCache?: WorkspaceCleanupFsActivityCache ): Promise { - const activityAt = await resolveWorkspaceCleanupActivityAt(repo, worktree, statPath, readTextFile) + const activityAt = await resolveWorkspaceCleanupActivityAt( + repo, + worktree, + statPath, + readTextFile, + fsActivityCache + ) if (activityAt <= worktree.lastActivityAt) { return worktree } @@ -35,27 +50,48 @@ async function statLocalPath(targetPath: string): Promise<{ mtimeMs: number }> { return { mtimeMs: Number(stats.mtimeMs) } } -async function readLocalTextFile(targetPath: string): Promise { - return readFile(targetPath, 'utf8') +async function readLocalTextFile( + targetPath: string, + options?: { tailBytes?: number } +): Promise { + if (options?.tailBytes === undefined) { + return readFile(targetPath, 'utf8') + } + const handle = await open(targetPath, 'r') + try { + const { size } = await handle.stat() + const length = Math.min(options.tailBytes, size) + if (length === 0) { + return '' + } + const { buffer, bytesRead } = await handle.read({ + buffer: Buffer.alloc(length), + position: size - length + }) + return buffer.toString('utf8', 0, bytesRead) + } finally { + await handle.close() + } } async function resolveWorkspaceCleanupActivityAt( repo: Repo, worktree: Worktree, statPath: StatPath, - readTextFile: ReadTextFile + readTextFile: ReadTextFile, + fsActivityCache?: WorkspaceCleanupFsActivityCache ): Promise { const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) if (repo.connectionId) { return persistedActivityAt } - const filesystemActivityAt = await getNewestLocalWorktreeActivityAt( - worktree.path, - statPath, - readTextFile - ) - return Math.max(persistedActivityAt, filesystemActivityAt) + let filesystemActivity = fsActivityCache?.get(worktree.path) + if (!filesystemActivity) { + filesystemActivity = getNewestLocalWorktreeActivityAt(worktree.path, statPath, readTextFile) + fsActivityCache?.set(worktree.path, filesystemActivity) + } + return Math.max(persistedActivityAt, await filesystemActivity) } // Why: best-effort only. Win32 stat over \\wsl.localhost (9P) can falsely @@ -97,20 +133,33 @@ async function readNewestReflogEntryAt( readTextFile: ReadTextFile ): Promise { try { - const lines = (await readTextFile(reflogPath)).split('\n') - for (let index = lines.length - 1; index >= 0; index -= 1) { - // The trailing timezone + tab anchors the capture, so no digit-count floor is needed. - const seconds = /\s(\d{1,11})\s[-+]\d{4}\t/.exec(lines[index] ?? '')?.[1] - if (seconds) { - return Number(seconds) * 1000 - } + // A tail cut can start mid-line; scanning backward for the last parseable + // entry tolerates that. + const tail = await readTextFile(reflogPath, { tailBytes: REFLOG_TAIL_BYTES }) + const newestFromTail = parseNewestReflogEntryAt(tail) + if (newestFromTail !== 0 || tail.length === 0) { + return newestFromTail } - return 0 + // Why: a single record longer than the tail window keeps its timestamp + // before the window starts; fall back to the full read in that rare case. + return parseNewestReflogEntryAt(await readTextFile(reflogPath)) } catch { return 0 } } +function parseNewestReflogEntryAt(reflog: string): number { + const lines = reflog.split('\n') + for (let index = lines.length - 1; index >= 0; index -= 1) { + // The trailing timezone + tab anchors the capture, so no digit-count floor is needed. + const seconds = /\s(\d{1,11})\s[-+]\d{4}\t/.exec(lines[index] ?? '')?.[1] + if (seconds) { + return Number(seconds) * 1000 + } + } + return 0 +} + async function readLocalWorktreeGitDir( worktreePath: string, gitPath: string, diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts new file mode 100644 index 00000000000..122f4bdd014 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -0,0 +1,453 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import path from 'node:path' +import type { Store } from '../persistence' +import type * as RepoWorktreesModule from '../repo-worktrees' +import type { GitStatusResult } from '../../shared/git-status-types' +import type { Repo } from '../../shared/repo-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import { WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT } from '../../shared/workspace-cleanup' + +const { + lstatMock, + readFileMock, + listRepoWorktreesMock, + getStatusMock, + gitExecFileAsyncMock, + getLocalProjectWorktreeGitOptionsMock, + getSshGitProviderMock +} = vi.hoisted(() => ({ + lstatMock: vi.fn(), + readFileMock: vi.fn(), + listRepoWorktreesMock: vi.fn(), + getStatusMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + getLocalProjectWorktreeGitOptionsMock: vi.fn(), + getSshGitProviderMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + readFile: readFileMock +})) + +vi.mock('../repo-worktrees', async () => { + const actual = await vi.importActual('../repo-worktrees') + return { + listRepoWorktrees: listRepoWorktreesMock, + createFolderWorktree: actual.createFolderWorktree + } +}) + +vi.mock('../git/status', () => ({ + getStatus: getStatusMock +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock +})) + +import { scanWorkspaceCleanup } from './workspace-cleanup-scan' + +const NOW = 1_700_000_000_000 +const DAY_MS = 24 * 60 * 60 * 1000 +const REPO: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: NOW, + symlinkPaths: ['node_modules'] +} +const FOLDER_REPO: Repo = { + ...REPO, + id: 'repo-folder', + path: '/folder-workspace', + displayName: 'Folder', + kind: 'folder' +} + +const GIT_WORKTREES: GitWorktreeInfo[] = [ + { + path: '/repo', + head: 'main123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo-old', + head: 'old123', + branch: 'refs/heads/old', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-recent', + head: 'recent123', + branch: 'refs/heads/recent', + isBare: false, + isMainWorktree: false + } +] + +function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + baseRef: 'origin/main', + ...overrides + } as WorktreeMeta +} + +const META_BY_WORKTREE_ID: Record = { + 'repo-1::/repo': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }), + 'repo-1::/repo-old': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }), + 'repo-1::/repo-recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }), + 'repo-folder::/folder-workspace': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }) +} + +function makeStore(repos: Repo[] = [REPO], allMeta: Record = {}): Store { + return { + getRepos: () => repos, + getWorktreeMeta: (worktreeId: string) => META_BY_WORKTREE_ID[worktreeId] ?? allMeta[worktreeId], + getAllWorktreeMeta: () => allMeta, + getGitHubCache: () => ({ pr: {}, issue: {} }) + } as unknown as Store +} + +describe('workspace cleanup broad scan opt-in', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + lstatMock.mockReset().mockResolvedValue({ mtimeMs: 0 }) + readFileMock.mockReset().mockRejectedValue(new Error('not a gitdir pointer')) + listRepoWorktreesMock.mockReset().mockResolvedValue(GIT_WORKTREES) + getStatusMock.mockReset().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + } satisfies GitStatusResult) + gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '0\n', stderr: '' }) + getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) + getSshGitProviderMock.mockReset().mockReturnValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('emits every worktree when the client opts into the full workspace list', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + + expect(result.errors).toEqual([]) + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo-1::/repo', + 'repo-1::/repo-old', + 'repo-1::/repo-recent' + ]) + }) + + it('preserves a worktree runtime host on cleanup candidates', async () => { + const runtimeMeta = makeWorktreeMeta({ + lastActivityAt: NOW - 40 * DAY_MS, + hostId: 'runtime:env-1' + }) + const store = { + ...makeStore(), + getWorktreeMeta: (worktreeId: string) => + worktreeId === 'repo-1::/repo-old' ? runtimeMeta : META_BY_WORKTREE_ID[worktreeId] + } as Store + + const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + + expect( + result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old') + ?.executionHostId + ).toBe('runtime:env-1') + }) + + it('keeps the legacy suggestion-only projection when the flag is absent', async () => { + const result = await scanWorkspaceCleanup(makeStore()) + + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo-1::/repo-old' + ]) + }) + + it('reports recent workspaces with no cleanup reasons', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + + const recent = result.candidates.find( + (candidate) => candidate.worktreeId === 'repo-1::/repo-recent' + ) + expect(recent).toMatchObject({ + reasons: [], + tier: 'review', + selectedByDefault: false, + lastActivityAt: NOW - 2 * DAY_MS + }) + }) + + it('reports the main worktree with a main-worktree blocker', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + + const main = result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo') + expect(main).toMatchObject({ + tier: 'protected', + selectedByDefault: false, + blockers: ['main-worktree'] + }) + }) + + it('reports folder workspaces with a folder-repo blocker', async () => { + const instanceId = `${FOLDER_REPO.id}::${FOLDER_REPO.path}::workspace:11111111-2222-4333-8444-555555555555` + const instanceMeta = makeWorktreeMeta({ + displayName: 'Folder session', + lastActivityAt: NOW - 2 * DAY_MS + }) + const result = await scanWorkspaceCleanup( + makeStore([FOLDER_REPO], { [instanceId]: instanceMeta }), + { includeAllWorkspaces: true } + ) + + expect(result.candidates).toEqual([ + expect.objectContaining({ + worktreeId: 'repo-folder::/folder-workspace', + tier: 'protected', + selectedByDefault: false, + blockers: expect.arrayContaining(['folder-repo', 'main-worktree']) + }), + expect.objectContaining({ + worktreeId: instanceId, + displayName: 'Folder session', + path: FOLDER_REPO.path, + tier: 'protected', + selectedByDefault: false, + blockers: ['folder-repo'] + }) + ]) + }) + + it('does not mark connected SSH folder workspaces as disconnected', async () => { + const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' } + getSshGitProviderMock.mockReturnValue({}) + + const result = await scanWorkspaceCleanup(makeStore([sshFolderRepo]), { + includeAllWorkspaces: true + }) + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]).toMatchObject({ + worktreeId: 'repo-folder::/folder-workspace', + connectionId: 'ssh-1', + blockers: ['main-worktree', 'folder-repo'] + }) + }) + + it('defers git evidence for rows that no cleanup decision can use', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + + // Why: only the inactive row can ever be queued, so it is the only row worth + // a git read; the rest stream immediately with empty evidence. + expect(getStatusMock).toHaveBeenCalledTimes(1) + expect(getStatusMock).toHaveBeenCalledWith('/repo-old', expect.anything()) + expect( + result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-recent')?.git + ).toEqual({ clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }) + expect( + result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old')?.git + ).toMatchObject({ clean: true, checkedAt: expect.any(Number) }) + }) + + it('does not read activity files for rows already known to be recent', async () => { + const recentWorktrees = ['/repo-recent-a', '/repo-recent-b'].map( + (worktreePath, index): GitWorktreeInfo => ({ + path: worktreePath, + head: `recent-${index}`, + branch: `refs/heads/recent-${index}`, + isBare: false, + isMainWorktree: false + }) + ) + listRepoWorktreesMock.mockResolvedValue(recentWorktrees) + const recentMeta = makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }) + const store = { + ...makeStore(), + getWorktreeMeta: () => recentMeta + } as unknown as Store + + const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + + expect(result.candidates).toHaveLength(recentWorktrees.length) + expect(readFileMock).not.toHaveBeenCalled() + expect(lstatMock).not.toHaveBeenCalled() + expect(getStatusMock).not.toHaveBeenCalled() + }) + + it('batches fleet progress instead of emitting one IPC payload per row', async () => { + const worktrees = [ + GIT_WORKTREES[0], + ...Array.from( + { length: 200 }, + (_, index): GitWorktreeInfo => ({ + path: `/repo-batch-${index}`, + head: `head-${index}`, + branch: `refs/heads/batch-${index}`, + isBare: false, + isMainWorktree: false + }) + ) + ] + listRepoWorktreesMock.mockResolvedValue(worktrees) + const onProgress = vi.fn() + + const result = await scanWorkspaceCleanup( + makeStore(), + { + scanId: 'fleet-scan', + includeAllWorkspaces: true, + skipGitWorktreeIds: worktrees.map((worktree) => `${REPO.id}::${worktree.path}`) + }, + { onProgress } + ) + + expect(onProgress.mock.calls.length).toBeLessThanOrEqual(2) + const progress = onProgress.mock.lastCall?.[0] + expect(progress).toMatchObject({ + scannedWorktreeCount: result.candidates.length, + totalWorktreeCount: result.candidates.length, + candidateMode: 'append' + }) + const streamedCandidates = onProgress.mock.calls.flatMap(([update]) => update.candidates) + expect(streamedCandidates.map((candidate) => candidate.worktreeId)).toEqual( + result.candidates.map((candidate) => candidate.worktreeId) + ) + }) + + it('still forces a git read for a focused scan of a recent workspace', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeId: 'repo-1::/repo-recent', + includeAllWorkspaces: true + }) + + expect(getStatusMock).toHaveBeenCalledTimes(1) + expect(lstatMock).toHaveBeenCalledWith('/repo-recent') + expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-recent', '.git')) + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]).toMatchObject({ + worktreeId: 'repo-1::/repo-recent', + reasons: [], + git: { clean: true, checkedAt: expect.any(Number) } + }) + }) + + it('lists a repo once for a targeted Git-evidence batch', async () => { + const targets = ['repo-1::/repo-recent', 'repo-1::/repo-old'] + + const result = await scanWorkspaceCleanup(makeStore(), { worktreeIds: targets }) + + expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1) + expect(getStatusMock).toHaveBeenCalledTimes(2) + expect(lstatMock).not.toHaveBeenCalled() + expect(new Set(result.candidates.map((candidate) => candidate.worktreeId))).toEqual( + new Set(targets) + ) + }) + + it('keeps the maximum targeted batch to one repo listing', async () => { + const worktrees = Array.from( + { length: WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT + 1 }, + (_, index): GitWorktreeInfo => ({ + path: `/repo-evidence-${index}`, + head: `head-${index}`, + branch: `refs/heads/evidence-${index}`, + isBare: false, + isMainWorktree: false + }) + ) + listRepoWorktreesMock.mockResolvedValue(worktrees) + + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeIds: worktrees.map((worktree) => `${REPO.id}::${worktree.path}`) + }) + + expect(result.candidates).toHaveLength(WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) + expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1) + expect(getStatusMock).toHaveBeenCalledTimes(WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) + expect(lstatMock).not.toHaveBeenCalled() + }) + + it('lists disconnected SSH workspaces only on an opt-in scan', async () => { + const sshRepo: Repo = { ...REPO, id: 'repo-ssh', connectionId: 'ssh-1' } + const allMeta: Record = { + 'repo-ssh::/remote/recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }) + } + + const legacy = await scanWorkspaceCleanup(makeStore([sshRepo], allMeta)) + const optIn = await scanWorkspaceCleanup(makeStore([sshRepo], allMeta), { + includeAllWorkspaces: true + }) + + expect(legacy.candidates).toEqual([]) + expect(optIn.candidates).toHaveLength(1) + expect(optIn.candidates[0]).toMatchObject({ + worktreeId: 'repo-ssh::/remote/recent', + blockers: ['ssh-disconnected'], + reasons: [] + }) + }) + + it('uses the backing path for disconnected SSH folder instances', async () => { + const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' } + const instanceId = `${sshFolderRepo.id}::${sshFolderRepo.path}::workspace:11111111-2222-4333-8444-555555555555` + const allMeta = { + [instanceId]: makeWorktreeMeta({ displayName: 'Remote folder session' }) + } + + const result = await scanWorkspaceCleanup(makeStore([sshFolderRepo], allMeta), { + includeAllWorkspaces: true + }) + + expect(result.candidates).toEqual([ + expect.objectContaining({ + worktreeId: instanceId, + path: sshFolderRepo.path, + branch: 'folder-workspace', + blockers: ['ssh-disconnected'] + }) + ]) + }) + + it('streams every row through scan progress on an opt-in scan', async () => { + const progress: { scannedWorktreeCount: number; totalWorktreeCount: number }[] = [] + + await scanWorkspaceCleanup( + makeStore(), + { includeAllWorkspaces: true, scanId: 'scan-all' }, + { onProgress: (event) => progress.push(event) } + ) + + expect(progress.at(-1)).toMatchObject({ + scanId: 'scan-all', + scannedWorktreeCount: 3, + totalWorktreeCount: 3 + }) + }) +}) diff --git a/src/main/ipc/workspace-cleanup-candidate.ts b/src/main/ipc/workspace-cleanup-candidate.ts index d6e93429b8a..58c56316acc 100644 --- a/src/main/ipc/workspace-cleanup-candidate.ts +++ b/src/main/ipc/workspace-cleanup-candidate.ts @@ -2,6 +2,7 @@ import type { IGitProvider } from '../providers/types' import { isFolderRepo } from '../../shared/repo-kind' import type { Repo } from '../../shared/repo-types' import type { Worktree } from '../../shared/worktree/types' +import { getWorktreeExecutionHostId } from '../../shared/execution-host' import { applyWorkspaceCleanupPolicy, createWorkspaceCleanupFingerprint, @@ -24,8 +25,9 @@ export async function buildWorkspaceCleanupCandidate(args: { provider: IGitProvider | null skipGit: boolean forceGitCheck: boolean + signal?: AbortSignal }): Promise { - const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck } = args + const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck, signal } = args const blockers: WorkspaceCleanupBlocker[] = [] const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(worktree, scannedAt) const repoIsFolder = isFolderRepo(repo) @@ -51,7 +53,7 @@ export async function buildWorkspaceCleanupCandidate(args: { const gitEvidence = !shouldReadGit ? createEmptyWorkspaceCleanupGitEvidence() - : await readWorkspaceCleanupGitEvidence(worktree, repo, provider) + : await readWorkspaceCleanupGitEvidence(worktree, repo, provider, signal) appendWorkspaceCleanupItems(blockers, gitEvidence.blockers) const candidateWithoutFingerprint: WorkspaceCleanupCandidate = { @@ -59,6 +61,7 @@ export async function buildWorkspaceCleanupCandidate(args: { repoId: repo.id, repoName: repo.displayName, connectionId: repo.connectionId ?? null, + executionHostId: getWorktreeExecutionHostId(worktree, repo), displayName: worktree.displayName, branch: shortWorkspaceCleanupBranchName(worktree.branch), path: worktree.path, @@ -103,6 +106,7 @@ export function buildWorkspaceCleanupCandidateFromError( repoId: repo.id, repoName: repo.displayName, connectionId: repo.connectionId ?? null, + executionHostId: getWorktreeExecutionHostId(worktree, repo), displayName: worktree.displayName, branch: shortWorkspaceCleanupBranchName(worktree.branch), path: worktree.path, diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts index 264e1eac9e2..e01484cb24a 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -1,4 +1,5 @@ import { basename } from 'node:path' +import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../shared/execution-host' import type { Store } from '../persistence' import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' @@ -7,7 +8,7 @@ import { createWorkspaceCleanupFingerprint, type WorkspaceCleanupCandidate } from '../../shared/workspace-cleanup' -import { splitWorktreeId } from '../../shared/worktree/id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { getNewestWorkspaceCleanupDiffCommentAt, getWorkspaceCleanupInactivityReasonsForWorkspace, @@ -18,17 +19,24 @@ export function synthesizeDisconnectedSshCleanupCandidates( store: Store, repo: Repo, scannedAt: number, - targetWorktreeId?: string + targetWorktreeIds?: ReadonlySet, + includeAllWorkspaces = false ): WorkspaceCleanupCandidate[] { const repoWorktreePrefix = `${repo.id}::` - if (targetWorktreeId) { - if (!targetWorktreeId.startsWith(repoWorktreePrefix)) { - return [] - } - // Why: focused delete preflight names one workspace already; walking all + if (targetWorktreeIds) { + const candidates: WorkspaceCleanupCandidate[] = [] + // Why: targeted refreshes name their workspaces already; walking all // persisted metadata is unnecessary for disconnected SSH repos. - const meta = store.getWorktreeMeta(targetWorktreeId) - return meta ? [createDisconnectedSshCandidate(repo, scannedAt, targetWorktreeId, meta)] : [] + for (const worktreeId of targetWorktreeIds) { + if (!worktreeId.startsWith(repoWorktreePrefix)) { + continue + } + const meta = store.getWorktreeMeta(worktreeId) + if (meta) { + candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta)) + } + } + return candidates } const candidates: WorkspaceCleanupCandidate[] = [] @@ -38,7 +46,7 @@ export function synthesizeDisconnectedSshCleanupCandidates( continue } const meta = allMeta[worktreeId] - if (!meta || !isWorkspaceInactiveForCleanup(meta, scannedAt)) { + if (!meta || (!includeAllWorkspaces && !isWorkspaceInactiveForCleanup(meta, scannedAt))) { continue } candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta)) @@ -52,7 +60,7 @@ function createDisconnectedSshCandidate( worktreeId: string, meta: WorktreeMeta ): WorkspaceCleanupCandidate { - const parsed = splitWorktreeId(worktreeId) + const parsed = splitWorktreeIdForFilesystem(worktreeId) const path = parsed?.worktreePath ?? worktreeId const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(meta, scannedAt) return applyWorkspaceCleanupPolicy({ @@ -60,6 +68,7 @@ function createDisconnectedSshCandidate( repoId: repo.id, repoName: repo.displayName, connectionId: repo.connectionId ?? null, + executionHostId: normalizeExecutionHostId(meta.hostId) ?? getRepoExecutionHostId(repo), displayName: meta.displayName || basename(path), branch: basename(path), path, diff --git a/src/main/ipc/workspace-cleanup-folder-workspaces.ts b/src/main/ipc/workspace-cleanup-folder-workspaces.ts new file mode 100644 index 00000000000..fc6112460cc --- /dev/null +++ b/src/main/ipc/workspace-cleanup-folder-workspaces.ts @@ -0,0 +1,23 @@ +import type { Store } from '../persistence' +import { createFolderWorktree } from '../repo-worktrees' +import type { Repo } from '../../shared/repo-types' +import type { Worktree } from '../../shared/worktree/types' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' +import { mergeWorktree } from './worktree-logic' + +export function listWorkspaceCleanupFolderWorkspaces(store: Store, repo: Repo): Worktree[] { + const rootId = `${repo.id}::${repo.path}` + const instancePrefix = `${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + const allMeta = store.getAllWorktreeMeta() + const worktreeIds = [ + rootId, + ...Object.keys(allMeta).filter((worktreeId) => worktreeId.startsWith(instancePrefix)) + ] + const folderWorktree = createFolderWorktree(repo) + + return worktreeIds.map((worktreeId) => ({ + ...mergeWorktree(repo.id, folderWorktree, store.getWorktreeMeta(worktreeId), repo.displayName), + id: worktreeId, + isMainWorktree: worktreeId === rootId + })) +} diff --git a/src/main/ipc/workspace-cleanup-git-evidence.ts b/src/main/ipc/workspace-cleanup-git-evidence.ts index 91aca462c7a..a54829c7c25 100644 --- a/src/main/ipc/workspace-cleanup-git-evidence.ts +++ b/src/main/ipc/workspace-cleanup-git-evidence.ts @@ -7,6 +7,7 @@ import type { Worktree } from '../../shared/worktree/types' import type { WorkspaceCleanupBlocker } from '../../shared/workspace-cleanup' import { WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + WorkspaceCleanupScanCancelledError, withWorkspaceCleanupTimeout } from './workspace-cleanup-scan-primitives' import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' @@ -32,7 +33,8 @@ export function createEmptyWorkspaceCleanupGitEvidence(): WorkspaceCleanupGitEvi export async function readWorkspaceCleanupGitEvidence( worktree: Worktree, repo: Repo, - provider: IGitProvider | null + provider: IGitProvider | null, + signal?: AbortSignal ): Promise { const blockers: WorkspaceCleanupBlocker[] = [] let status: GitStatusResult @@ -49,9 +51,13 @@ export async function readWorkspaceCleanupGitEvidence( ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out reading git status.' + 'Timed out reading git status.', + signal ) - } catch { + } catch (error) { + if (error instanceof WorkspaceCleanupScanCancelledError) { + throw error + } return { ...createEmptyWorkspaceCleanupGitEvidence(), blockers: ['git-status-error'] @@ -76,7 +82,7 @@ export async function readWorkspaceCleanupGitEvidence( blockers.push('unpushed-commits') } if (clean && upstreamAhead === null) { - const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider) + const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider, signal) if (unpushedCommitCount === null) { blockers.push('unknown-base') } else if (unpushedCommitCount > 0) { @@ -96,7 +102,8 @@ export async function readWorkspaceCleanupGitEvidence( async function readUnpushedCommitCount( worktree: Worktree, repo: Repo, - provider: IGitProvider | null + provider: IGitProvider | null, + signal?: AbortSignal ): Promise { try { const result = await withWorkspaceCleanupTimeout( @@ -110,11 +117,15 @@ async function readUnpushedCommitCount( signal }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out checking unpushed commits.' + 'Timed out checking unpushed commits.', + signal ) const count = Number.parseInt(result.stdout.trim(), 10) return Number.isFinite(count) ? count : null - } catch { + } catch (error) { + if (error instanceof WorkspaceCleanupScanCancelledError) { + throw error + } return null } } diff --git a/src/main/ipc/workspace-cleanup-process-preflight.test.ts b/src/main/ipc/workspace-cleanup-process-preflight.test.ts new file mode 100644 index 00000000000..1c6b8e78c30 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-process-preflight.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ipcMain } from 'electron' +import type { Store } from '../persistence' + +const { getSshPtyProviderMock } = vi.hoisted(() => ({ + getSshPtyProviderMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn() + } +})) + +vi.mock('./pty', () => ({ + getSshPtyProvider: getSshPtyProviderMock +})) + +vi.mock('../memory/pty-registry', () => ({ + listRegisteredPtys: vi.fn(() => []) +})) + +vi.mock('../workspace-cleanup-scan-snapshot', () => ({ + persistWorkspaceCleanupScanResult: vi.fn(async () => undefined), + readWorkspaceCleanupScanSnapshot: vi.fn(async () => null) +})) + +vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({ + beginWorkspaceCleanupRemovalSnapshotPruneBatch: vi.fn(), + finishWorkspaceCleanupRemovalSnapshotPruneBatch: vi.fn(async () => undefined), + recordWorkspaceCleanupRemovalSnapshotPrune: vi.fn() +})) + +import { registerWorkspaceCleanupHandlers } from './workspace-cleanup' + +function makeEmptyStore(): Store { + return { + getProfileStorageDirectory: () => '/profile-a', + getRepos: () => [], + getWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => ({}), + getGitHubCache: () => ({ pr: {}, issue: {} }) + } as unknown as Store +} + +function getPreflightHandler(): ((...args: never[]) => unknown) | undefined { + return vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1] +} + +describe('workspace cleanup process preflight', () => { + beforeEach(() => { + vi.mocked(ipcMain.handle).mockReset() + getSshPtyProviderMock.mockReset() + }) + + it('reports local processes that workspace deletion would kill', async () => { + const localProvider = { + listProcesses: vi.fn().mockResolvedValue([ + { + id: 'repo-1::/repo-feature@@session-1', + cwd: '/repo-feature', + title: 'zsh' + } + ]) + } + registerWorkspaceCleanupHandlers(makeEmptyStore(), { + runtime: { + hasTerminalsForWorktree: vi.fn().mockResolvedValue(false) + } as never, + getLocalPtyProvider: () => localProvider as never + }) + + await expect( + getPreflightHandler()?.({} as never, { worktreeId: 'repo-1::/repo-feature' } as never) + ).resolves.toEqual({ + hasKillableProcesses: true + }) + }) + + it('reports SSH processes inside the remote workspace path', async () => { + getSshPtyProviderMock.mockReturnValue({ + listProcesses: vi.fn().mockResolvedValue([ + { + id: 'remote-session-1', + cwd: '/remote/repo-feature/subdir', + title: 'codex' + } + ]) + }) + registerWorkspaceCleanupHandlers(makeEmptyStore(), { + runtime: { + hasTerminalsForWorktree: vi.fn().mockResolvedValue(false) + } as never + }) + + await expect( + getPreflightHandler()?.( + {} as never, + { + worktreeId: 'repo-ssh::/remote/repo-feature', + connectionId: 'ssh-1', + worktreePath: '/remote/repo-feature' + } as never + ) + ).resolves.toEqual({ + hasKillableProcesses: true + }) + }) +}) diff --git a/src/main/ipc/workspace-cleanup-progress-emitter.ts b/src/main/ipc/workspace-cleanup-progress-emitter.ts new file mode 100644 index 00000000000..7a2a5394963 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-progress-emitter.ts @@ -0,0 +1,83 @@ +import type { + WorkspaceCleanupCandidate, + WorkspaceCleanupScanError, + WorkspaceCleanupScanProgress +} from '../../shared/workspace-cleanup' +import { appendWorkspaceCleanupItems } from './workspace-cleanup-scan-primitives' + +const WORKSPACE_CLEANUP_PROGRESS_EMIT_INTERVAL_MS = 100 + +export type WorkspaceCleanupScanOptions = { + onProgress?: (progress: WorkspaceCleanupScanProgress) => void + signal?: AbortSignal +} + +export type WorkspaceCleanupProgressEmitter = { + addDiscovered: (count: number) => void + addCandidate: (candidate: WorkspaceCleanupCandidate) => void + addErrors: (errors: WorkspaceCleanupScanError[]) => void + flush: () => void +} + +/** Caps renderer projection churn while preserving an immediate final progress state. */ +export function createWorkspaceCleanupProgressEmitter( + scanId: string | undefined, + scannedAt: number, + options: WorkspaceCleanupScanOptions +): WorkspaceCleanupProgressEmitter { + const errors: WorkspaceCleanupScanError[] = [] + let pendingCandidates: WorkspaceCleanupCandidate[] = [] + let totalWorktreeCount = 0 + let scannedWorktreeCount = 0 + let dirty = false + let emittedInitialDiscovery = false + let timer: NodeJS.Timeout | null = null + const flush = (): void => { + if (!scanId || !dirty) { + return + } + if (timer) { + clearTimeout(timer) + timer = null + } + dirty = false + const candidates = pendingCandidates + pendingCandidates = [] + options.onProgress?.({ + scanId, + scannedAt, + totalWorktreeCount, + scannedWorktreeCount, + candidates, + errors: [...errors], + candidateMode: 'append' + }) + } + const schedule = (): void => { + if (!scanId) { + return + } + dirty = true + timer ??= setTimeout(flush, WORKSPACE_CLEANUP_PROGRESS_EMIT_INTERVAL_MS) + } + return { + addDiscovered: (count) => { + totalWorktreeCount += count + schedule() + if (!emittedInitialDiscovery) { + emittedInitialDiscovery = true + flush() + } + }, + addCandidate: (candidate) => { + pendingCandidates.push(candidate) + scannedWorktreeCount += 1 + schedule() + }, + addErrors: (newErrors) => { + appendWorkspaceCleanupItems(errors, newErrors) + schedule() + }, + flush + } +} diff --git a/src/main/ipc/workspace-cleanup-scan-eligibility.ts b/src/main/ipc/workspace-cleanup-scan-eligibility.ts new file mode 100644 index 00000000000..d6b2c94b218 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-scan-eligibility.ts @@ -0,0 +1,35 @@ +import type { Worktree } from '../../shared/worktree/types' +import { getPersistedWorkspaceCleanupActivityAt } from '../../shared/workspace-cleanup' +import { isWorkspaceInactiveForCleanup } from './workspace-cleanup-candidate' + +/** + * Which worktrees a broad (non-targeted) cleanup scan is allowed to emit. + * + * Why: a client that has not opted into the full workspace list still renders + * the suggestion-only views, so the host must keep publishing exactly the rows + * it always did — inactive, non-main, non-folder worktrees — or an older client + * is flooded with rows it has no view for. + */ +export function shouldScanBroadWorkspaceCleanupWorktree(args: { + includeAllWorkspaces: boolean + repoIsFolder: boolean + worktree: Worktree + scannedAt: number +}): boolean { + const { includeAllWorkspaces, repoIsFolder, worktree, scannedAt } = args + if (includeAllWorkspaces) { + return true + } + if (repoIsFolder || worktree.isMainWorktree) { + return false + } + // Why: persisted stamps only; the filesystem read that refines them is the + // expensive part this pre-filter exists to avoid. + return isWorkspaceInactiveForCleanup( + { + isArchived: worktree.isArchived, + lastActivityAt: getPersistedWorkspaceCleanupActivityAt(worktree) + }, + scannedAt + ) +} diff --git a/src/main/ipc/workspace-cleanup-scan-primitives.ts b/src/main/ipc/workspace-cleanup-scan-primitives.ts index 8b4c5c839b4..93af63562c2 100644 --- a/src/main/ipc/workspace-cleanup-scan-primitives.ts +++ b/src/main/ipc/workspace-cleanup-scan-primitives.ts @@ -4,6 +4,19 @@ import type { WorkspaceCleanupScanError } from '../../shared/workspace-cleanup' export const WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS = 8_000 +export class WorkspaceCleanupScanCancelledError extends Error { + constructor() { + super('Workspace cleanup scan cancelled') + this.name = 'WorkspaceCleanupScanCancelledError' + } +} + +export function throwIfWorkspaceCleanupScanAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new WorkspaceCleanupScanCancelledError() + } +} + export function appendWorkspaceCleanupItems(target: T[], entries: readonly T[]): void { // Why: cleanup can aggregate generated-size worktree batches; spreading // those batches into push can exceed JavaScript's argument limit. @@ -35,15 +48,25 @@ export async function mapWorkspaceCleanupWithConcurrency( export async function withWorkspaceCleanupTimeout( run: (signal: AbortSignal) => Promise, timeoutMs: number, - message: string + message: string, + parentSignal?: AbortSignal ): Promise { const controller = new AbortController() let timeoutId: NodeJS.Timeout | undefined + let onParentAbort: (() => void) | undefined const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { controller.abort() reject(new Error(message)) }, timeoutMs) + onParentAbort = () => { + controller.abort() + reject(new WorkspaceCleanupScanCancelledError()) + } + parentSignal?.addEventListener('abort', onParentAbort, { once: true }) + if (parentSignal?.aborted) { + onParentAbort() + } }) try { return await Promise.race([run(controller.signal), timeoutPromise]) @@ -51,6 +74,9 @@ export async function withWorkspaceCleanupTimeout( if (timeoutId) { clearTimeout(timeoutId) } + if (onParentAbort) { + parentSignal?.removeEventListener('abort', onParentAbort) + } } } diff --git a/src/main/ipc/workspace-cleanup-scan-targets.ts b/src/main/ipc/workspace-cleanup-scan-targets.ts new file mode 100644 index 00000000000..b437c13736e --- /dev/null +++ b/src/main/ipc/workspace-cleanup-scan-targets.ts @@ -0,0 +1,31 @@ +import { splitWorktreeId } from '../../shared/worktree/id' +import { + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT, + type WorkspaceCleanupScanArgs +} from '../../shared/workspace-cleanup' + +export function hasTargetedWorkspaceCleanupScan(args: WorkspaceCleanupScanArgs): boolean { + return typeof args.worktreeId === 'string' || Array.isArray(args.worktreeIds) +} + +export function getTargetWorktreeIdsByRepo( + args: WorkspaceCleanupScanArgs +): Map> { + const requestedIds = args.worktreeId + ? [args.worktreeId] + : (args.worktreeIds ?? []).slice(0, WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) + const idsByRepo = new Map>() + for (const worktreeId of requestedIds) { + if (typeof worktreeId !== 'string') { + continue + } + const parsed = splitWorktreeId(worktreeId) + if (!parsed) { + continue + } + const repoIds = idsByRepo.get(parsed.repoId) ?? new Set() + repoIds.add(worktreeId) + idsByRepo.set(parsed.repoId, repoIds) + } + return idsByRepo +} diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 7072199ffcd..da6f9821356 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -1,23 +1,24 @@ import type { Store } from '../persistence' -import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { IGitProvider } from '../providers/types' import { isFolderRepo } from '../../shared/repo-kind' import type { Repo } from '../../shared/repo-types' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import { mergeWorktree } from './worktree-logic' -import { splitWorktreeId } from '../../shared/worktree/id' import type { WorkspaceCleanupCandidate, - WorkspaceCleanupScanError, WorkspaceCleanupScanArgs, - WorkspaceCleanupScanProgress, + WorkspaceCleanupScanError, WorkspaceCleanupScanResult } from '../../shared/workspace-cleanup' -import { getPersistedWorkspaceCleanupActivityAt } from '../../shared/workspace-cleanup' +import { + handleRepoWorktreeListError, + listCleanupGitWorktrees +} from './workspace-cleanup-worktree-listing' +import { shouldScanBroadWorkspaceCleanupWorktree } from './workspace-cleanup-scan-eligibility' import { resolvePersistedWorkspaceCleanupActivityWorktree, - resolveWorkspaceCleanupActivityWorktree + resolveWorkspaceCleanupActivityWorktree, + type WorkspaceCleanupFsActivityCache } from './workspace-cleanup-activity' import { buildWorkspaceCleanupCandidate, @@ -27,19 +28,26 @@ import { import { synthesizeDisconnectedSshCleanupCandidates } from './workspace-cleanup-disconnected-ssh' import { WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + WorkspaceCleanupScanCancelledError, appendWorkspaceCleanupItems, - createWorkspaceCleanupScanError, mapWorkspaceCleanupWithConcurrency, - toSafeWorkspaceCleanupRepoScanError, + throwIfWorkspaceCleanupScanAborted, withWorkspaceCleanupTimeout } from './workspace-cleanup-scan-primitives' -import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import { listWorkspaceCleanupFolderWorkspaces } from './workspace-cleanup-folder-workspaces' +import { + createWorkspaceCleanupProgressEmitter, + type WorkspaceCleanupScanOptions +} from './workspace-cleanup-progress-emitter' +import { + getTargetWorktreeIdsByRepo, + hasTargetedWorkspaceCleanupScan +} from './workspace-cleanup-scan-targets' const WORKTREE_SCAN_CONCURRENCY = 3 - -type WorkspaceCleanupScanOptions = { - onProgress?: (progress: WorkspaceCleanupScanProgress) => void -} +// Why: SSH repos pay a worktree-list round trip each; strictly serial repos +// made scan wall-clock the sum of per-repo network latencies. +const REPO_SCAN_CONCURRENCY = 2 type WorkspaceCleanupScanRepoProgress = { onWorktreesDiscovered?: (count: number) => void @@ -47,45 +55,54 @@ type WorkspaceCleanupScanRepoProgress = { onErrors?: (errors: WorkspaceCleanupScanError[]) => void } -type WorkspaceCleanupProgressEmitter = { - addDiscovered: (count: number) => void - addCandidate: (candidate: WorkspaceCleanupCandidate) => void - addErrors: (errors: WorkspaceCleanupScanError[]) => void -} - export async function scanWorkspaceCleanup( store: Store, args: WorkspaceCleanupScanArgs = {}, options: WorkspaceCleanupScanOptions = {} ): Promise { + throwIfWorkspaceCleanupScanAborted(options.signal) const scannedAt = Date.now() - const parsedTarget = args.worktreeId ? splitWorktreeId(args.worktreeId) : null - if (args.worktreeId && !parsedTarget) { + const targetWorktreeIdsByRepo = getTargetWorktreeIdsByRepo(args) + if (hasTargetedWorkspaceCleanupScan(args) && targetWorktreeIdsByRepo.size === 0) { return { scannedAt, candidates: [], errors: [] } } - const repos = parsedTarget - ? store.getRepos().filter((repo) => repo.id === parsedTarget.repoId) - : store.getRepos() - const progress = createProgressEmitter(args.scanId, scannedAt, options) + const repos = + targetWorktreeIdsByRepo.size > 0 + ? store.getRepos().filter((repo) => targetWorktreeIdsByRepo.has(repo.id)) + : store.getRepos() + const progress = createWorkspaceCleanupProgressEmitter(args.scanId, scannedAt, options) const errors: WorkspaceCleanupScanResult['errors'] = [] const candidates: WorkspaceCleanupCandidate[] = [] - for (const repo of repos) { - const result = await scanRepoWorkspaces({ - store, - repo, - scannedAt, - targetWorktreeId: args.worktreeId, - skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []), - onWorktreesDiscovered: progress.addDiscovered, - onCandidateScanned: progress.addCandidate, - onErrors: progress.addErrors - }) - appendWorkspaceCleanupItems(candidates, result.candidates) - appendWorkspaceCleanupItems(errors, result.errors) + try { + const repoResults = await mapWorkspaceCleanupWithConcurrency( + repos, + REPO_SCAN_CONCURRENCY, + async (repo) => { + throwIfWorkspaceCleanupScanAborted(options.signal) + return scanRepoWorkspaces({ + store, + repo, + scannedAt, + targetWorktreeIds: targetWorktreeIdsByRepo.get(repo.id), + refreshTargetActivity: args.worktreeId !== undefined || args.refreshActivity === true, + includeAllWorkspaces: args.includeAllWorkspaces === true, + skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []), + signal: options.signal, + onWorktreesDiscovered: progress.addDiscovered, + onCandidateScanned: progress.addCandidate, + onErrors: progress.addErrors + }) + } + ) + for (const result of repoResults) { + appendWorkspaceCleanupItems(candidates, result.candidates) + appendWorkspaceCleanupItems(errors, result.errors) + } + return { scannedAt, candidates, errors } + } finally { + progress.flush() } - - return { scannedAt, candidates, errors } } async function scanRepoWorkspaces( @@ -93,16 +110,22 @@ async function scanRepoWorkspaces( store: Store repo: Repo scannedAt: number - targetWorktreeId?: string + targetWorktreeIds?: ReadonlySet + refreshTargetActivity: boolean + includeAllWorkspaces: boolean skipGitWorktreeIds: Set + signal?: AbortSignal } & WorkspaceCleanupScanRepoProgress ): Promise { const { store, repo, scannedAt, - targetWorktreeId, + targetWorktreeIds, + refreshTargetActivity, + includeAllWorkspaces, skipGitWorktreeIds, + signal, onWorktreesDiscovered, onCandidateScanned, onErrors @@ -113,17 +136,35 @@ async function scanRepoWorkspaces( let gitWorktrees: GitWorktreeInfo[] = [] try { - const discovered = await listCleanupGitWorktrees(store, repo, repoIsFolder) + const discovered = await listCleanupGitWorktrees(store, repo, repoIsFolder, signal) provider = discovered.provider gitWorktrees = discovered.gitWorktrees } catch (error) { - return handleRepoWorktreeListError({ repo, targetWorktreeId, scannedAt, error, onErrors }) + if (error instanceof WorkspaceCleanupScanCancelledError) { + throw error + } + return handleRepoWorktreeListError({ + repo, + targeted: targetWorktreeIds !== undefined, + scannedAt, + error, + onErrors + }) } if (repo.connectionId && !provider) { - const candidates = targetWorktreeId - ? synthesizeDisconnectedSshCleanupCandidates(store, repo, scannedAt, targetWorktreeId) - : [] + // Why: a disconnected host still owns real workspaces; the full list shows + // them (blocked), while legacy scans keep omitting what they cannot inspect. + const candidates = + targetWorktreeIds || includeAllWorkspaces + ? synthesizeDisconnectedSshCleanupCandidates( + store, + repo, + scannedAt, + targetWorktreeIds, + includeAllWorkspaces + ) + : [] onWorktreesDiscovered?.(candidates.length) for (const candidate of candidates) { onCandidateScanned?.(candidate) @@ -131,43 +172,84 @@ async function scanRepoWorkspaces( return { scannedAt, candidates, errors: [] } } - const mergedWorktrees = gitWorktrees.map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = store.getWorktreeMeta(worktreeId) - return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) - }) - const candidateWorktrees = targetWorktreeId - ? mergedWorktrees.filter((worktree) => worktree.id === targetWorktreeId) + const mergedWorktrees = + repoIsFolder && includeAllWorkspaces + ? listWorkspaceCleanupFolderWorkspaces(store, repo) + : gitWorktrees.map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + const meta = store.getWorktreeMeta(worktreeId) + return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + }) + // Why: with includeAllWorkspaces the browser shows every workspace and lets + // filters narrow it; an age threshold here would hide rows from all views. + const candidateWorktrees = targetWorktreeIds + ? mergedWorktrees.filter((worktree) => targetWorktreeIds.has(worktree.id)) : mergedWorktrees.filter((worktree) => - shouldResolveBroadWorkspaceCleanupActivity(repoIsFolder, worktree, scannedAt) + shouldScanBroadWorkspaceCleanupWorktree({ + includeAllWorkspaces, + repoIsFolder, + worktree, + scannedAt + }) ) + // Why: with a target list or the full-list browser, every filtered row will + // be reported; counting them upfront keeps the progress bar honest instead + // of advancing discovered/scanned in lockstep at ~100%. + const reportDiscoveredUpfront = targetWorktreeIds !== undefined || includeAllWorkspaces + if (reportDiscoveredUpfront && candidateWorktrees.length > 0) { + onWorktreesDiscovered?.(candidateWorktrees.length) + } // Why: fs stat has no cancellation, so on a hung network/WSL mount every // timed-out row would abandon more threadpool work. After the first timeout, // stop statting this repo and use persisted activity only. let activityStatsUnavailable = false + const fsActivityCache: WorkspaceCleanupFsActivityCache = new Map() const candidatesWithSkipped = await mapWorkspaceCleanupWithConcurrency( candidateWorktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { + throwIfWorkspaceCleanupScanAborted(signal) // Why: externally-created worktrees can miss Orca activity stamps; local // filesystem metadata is a conservative guard before suggesting deletion. - const worktreeWithActivity = activityStatsUnavailable - ? resolvePersistedWorkspaceCleanupActivityWorktree(worktree) - : await resolveCleanupActivityWithTimeout(repo, worktree, () => { - activityStatsUnavailable = true - }) - if (!targetWorktreeId && !isWorkspaceInactiveForCleanup(worktreeWithActivity, scannedAt)) { + const persistedActivityWorktree = resolvePersistedWorkspaceCleanupActivityWorktree(worktree) + const persistedActivityIsRecent = !isWorkspaceInactiveForCleanup( + persistedActivityWorktree, + scannedAt + ) + const worktreeWithActivity = + activityStatsUnavailable || + (targetWorktreeIds ? !refreshTargetActivity : persistedActivityIsRecent) + ? persistedActivityWorktree + : await resolveCleanupActivityWithTimeout( + repo, + worktree, + () => { + activityStatsUnavailable = true + }, + signal, + fsActivityCache + ) + const isInactive = isWorkspaceInactiveForCleanup(worktreeWithActivity, scannedAt) + if (!targetWorktreeIds && !includeAllWorkspaces && !isInactive) { return null } - onWorktreesDiscovered?.(1) + if (!reportDiscoveredUpfront) { + onWorktreesDiscovered?.(1) + } const candidate = await buildWorkspaceCleanupCandidate({ repo, worktree: worktreeWithActivity, scannedAt, provider, - skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id), - forceGitCheck: Boolean(targetWorktreeId) + // Why: a row with no inactivity reason can never be queued or selected, + // so full-fleet scans stream it now and let a focused scan read git later. + skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id) || !isInactive, + forceGitCheck: Boolean(targetWorktreeIds), + signal }).catch((error) => { + if (error instanceof WorkspaceCleanupScanCancelledError) { + throw error + } console.error('Workspace cleanup candidate scan failed', error) return buildWorkspaceCleanupCandidateFromError(repo, worktreeWithActivity, scannedAt) }) @@ -185,128 +267,30 @@ async function scanRepoWorkspaces( async function resolveCleanupActivityWithTimeout( repo: Repo, worktree: Worktree, - onActivityStatsUnavailable: () => void + onActivityStatsUnavailable: () => void, + signal?: AbortSignal, + fsActivityCache?: WorkspaceCleanupFsActivityCache ): Promise { try { return await withWorkspaceCleanupTimeout( - () => resolveWorkspaceCleanupActivityWorktree(repo, worktree), + () => + resolveWorkspaceCleanupActivityWorktree( + repo, + worktree, + undefined, + undefined, + fsActivityCache + ), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out reading worktree activity.' + 'Timed out reading worktree activity.', + signal ) } catch (error) { + if (error instanceof WorkspaceCleanupScanCancelledError) { + throw error + } onActivityStatsUnavailable() console.warn('Workspace cleanup activity scan failed', error) return resolvePersistedWorkspaceCleanupActivityWorktree(worktree) } } - -function shouldResolveBroadWorkspaceCleanupActivity( - repoIsFolder: boolean, - worktree: Worktree, - scannedAt: number -): boolean { - if (repoIsFolder || worktree.isMainWorktree) { - return false - } - return isWorkspaceInactiveForCleanup( - { - isArchived: worktree.isArchived, - lastActivityAt: getPersistedWorkspaceCleanupActivityAt(worktree) - }, - scannedAt - ) -} - -async function listCleanupGitWorktrees( - store: Store, - repo: Repo, - repoIsFolder: boolean -): Promise<{ provider: IGitProvider | null; gitWorktrees: GitWorktreeInfo[] }> { - if (repoIsFolder) { - return { provider: null, gitWorktrees: [createFolderWorktree(repo)] } - } - if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) ?? null - if (!provider) { - // Why: cleanup should reflect only workspaces Orca can currently inspect. - return { provider: null, gitWorktrees: [] } - } - return { - provider, - gitWorktrees: await withWorkspaceCleanupTimeout( - (signal) => provider.listWorktrees(repo.path, { signal }), - WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out listing SSH worktrees.' - ) - } - } - const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - return { - provider: null, - gitWorktrees: await withWorkspaceCleanupTimeout( - (signal) => listRepoWorktrees(repo, { ...localGitOptions, signal }), - WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out listing worktrees.' - ) - } -} - -function handleRepoWorktreeListError(args: { - repo: Repo - targetWorktreeId?: string - scannedAt: number - error: unknown - onErrors?: (errors: WorkspaceCleanupScanError[]) => void -}): WorkspaceCleanupScanResult { - const { repo, targetWorktreeId, scannedAt, error, onErrors } = args - console.error('Workspace cleanup repo scan failed', error) - if (repo.connectionId && !targetWorktreeId) { - // Why: broad cleanup only shows remote workspaces Orca can inspect now. - // A connected SSH repo that fails mid-scan is omitted, not bannered. - return { scannedAt, candidates: [], errors: [] } - } - const errors = [createWorkspaceCleanupScanError(repo, toSafeWorkspaceCleanupRepoScanError(error))] - onErrors?.(errors) - return { scannedAt, candidates: [], errors } -} - -function createProgressEmitter( - scanId: string | undefined, - scannedAt: number, - options: WorkspaceCleanupScanOptions -): WorkspaceCleanupProgressEmitter { - const errors: WorkspaceCleanupScanError[] = [] - let totalWorktreeCount = 0 - let scannedWorktreeCount = 0 - const emit = ( - candidates: WorkspaceCleanupCandidate[], - candidateMode: WorkspaceCleanupScanProgress['candidateMode'] = 'snapshot' - ): void => { - if (!scanId) { - return - } - options.onProgress?.({ - scanId, - scannedAt, - totalWorktreeCount, - scannedWorktreeCount, - candidates, - errors: [...errors], - candidateMode - }) - } - return { - addDiscovered: (count) => { - totalWorktreeCount += count - emit([], 'append') - }, - addCandidate: (candidate) => { - scannedWorktreeCount += 1 - emit([candidate], 'append') - }, - addErrors: (newErrors) => { - appendWorkspaceCleanupItems(errors, newErrors) - emit([], 'append') - } - } -} diff --git a/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts b/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts new file mode 100644 index 00000000000..8bfbc586959 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts @@ -0,0 +1,281 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ipcMain } from 'electron' +import type { Store } from '../persistence' + +const { persistScanResultMock, readScanSnapshotMock, scanWorkspaceCleanupMock } = vi.hoisted( + () => ({ + persistScanResultMock: vi.fn(), + readScanSnapshotMock: vi.fn(), + scanWorkspaceCleanupMock: vi.fn() + }) +) + +const { beginPruneBatchMock, finishPruneBatchMock, recordPruneMock } = vi.hoisted(() => ({ + beginPruneBatchMock: vi.fn(), + finishPruneBatchMock: vi.fn().mockResolvedValue(undefined), + recordPruneMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn() + } +})) + +vi.mock('../workspace-cleanup-scan-snapshot', () => ({ + persistWorkspaceCleanupScanResult: persistScanResultMock, + readWorkspaceCleanupScanSnapshot: readScanSnapshotMock +})) + +vi.mock('./workspace-cleanup-scan', () => ({ + scanWorkspaceCleanup: scanWorkspaceCleanupMock +})) + +vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({ + beginWorkspaceCleanupRemovalSnapshotPruneBatch: beginPruneBatchMock, + finishWorkspaceCleanupRemovalSnapshotPruneBatch: finishPruneBatchMock, + recordWorkspaceCleanupRemovalSnapshotPrune: recordPruneMock +})) + +import { registerWorkspaceCleanupHandlers } from './workspace-cleanup' + +const NOW = 1_700_000_000_000 + +function makeScanEvent(senderOverrides: Record = {}): never { + return { + sender: { + id: 1, + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + ...senderOverrides + } + } as never +} + +function makeEmptyStore(): Store { + return { + getProfileStorageDirectory: () => '/profile-a', + getRepos: () => [], + getWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => ({}), + getGitHubCache: () => ({ pr: {}, issue: {} }) + } as unknown as Store +} + +describe('workspace cleanup snapshot IPC', () => { + beforeEach(() => { + vi.mocked(ipcMain.handle).mockClear() + persistScanResultMock.mockReset().mockResolvedValue(undefined) + readScanSnapshotMock.mockReset() + scanWorkspaceCleanupMock.mockReset().mockImplementation(async (_store, args, options) => { + const result = { scannedAt: NOW, candidates: [], errors: [] } + options.onProgress?.({ + scanId: args.scanId, + scannedAt: NOW, + scannedWorktreeCount: 0, + totalWorktreeCount: 0, + candidates: [], + errors: [] + }) + return result + }) + beginPruneBatchMock.mockReset() + finishPruneBatchMock.mockReset().mockResolvedValue(undefined) + recordPruneMock.mockReset() + }) + + it('persists the completed scan result after replying', async () => { + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + + const args = { includeAllWorkspaces: true } + const result = await handler?.(makeScanEvent(), args) + + expect(persistScanResultMock).toHaveBeenCalledWith('/profile-a', args, result) + }) + + it('does not rewrite the fleet snapshot for a focused scan', async () => { + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + + await handler?.(makeScanEvent(), { worktreeId: 'repo-1::/repo-feature' }) + + expect(persistScanResultMock).not.toHaveBeenCalled() + }) + + it('does not rewrite the fleet snapshot for a targeted evidence batch', async () => { + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + + await handler?.(makeScanEvent(), { + worktreeIds: ['repo-1::/repo-a', 'repo-1::/repo-b'] + }) + + expect(persistScanResultMock).not.toHaveBeenCalled() + }) + + it('does not persist an empty-target scan over the fleet snapshot', async () => { + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + + // Why: worktreeIds: [] is a targeted scan that returns nothing; persisting + // it as broad would wipe the fleet cache. + await handler?.(makeScanEvent(), { worktreeIds: [], includeAllWorkspaces: true }) + + expect(persistScanResultMock).not.toHaveBeenCalled() + }) + + it('stops streaming progress after the invoking renderer is destroyed', async () => { + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + const send = vi.fn(() => { + throw new Error('Object has been destroyed') + }) + + await expect( + handler?.(makeScanEvent({ isDestroyed: () => true, send }), { scanId: 'scan-1' }) + ).resolves.toEqual({ scannedAt: NOW, candidates: [], errors: [] }) + expect(send).not.toHaveBeenCalled() + }) + + it('aborts the scan when the invoking renderer is destroyed mid-flight', async () => { + let abortListener: (() => void) | undefined + scanWorkspaceCleanupMock.mockImplementation((_store, _args, options) => { + return new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(new Error('cancelled')), { + once: true + }) + }) + }) + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + const event = makeScanEvent({ + once: vi.fn((eventName: string, listener: () => void) => { + if (eventName === 'destroyed') { + abortListener = listener + } + }) + }) + + const scan = handler?.(event, { includeAllWorkspaces: true }) + abortListener?.() + await expect(scan).rejects.toThrow('cancelled') + }) + + it('keeps legacy suggestion-only and full broad scans isolated across modes', async () => { + scanWorkspaceCleanupMock.mockImplementation((_store, _args, options) => { + return new Promise((resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(new Error('cancelled')), { + once: true + }) + setTimeout(() => resolve({ scannedAt: NOW, candidates: [], errors: [] }), 5) + }) + }) + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + + // Why: different includeAllWorkspaces modes are separate lanes; one must + // not abort the other even from the same renderer. + const [legacy, full] = await Promise.allSettled([ + handler?.(makeScanEvent({ id: 7 }), { includeAllWorkspaces: false }) as Promise, + handler?.(makeScanEvent({ id: 7 }), { includeAllWorkspaces: true }) as Promise + ]) + + expect(legacy?.status).toBe('fulfilled') + expect(full?.status).toBe('fulfilled') + }) + + it('supersedes a concurrent broad scan from the same renderer', async () => { + const settlements: Promise[] = [] + scanWorkspaceCleanupMock.mockImplementation((_store, _args, options) => { + return new Promise((resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(new Error('cancelled')), { + once: true + }) + setTimeout(() => resolve({ scannedAt: NOW, candidates: [], errors: [] }), 5) + }) + }) + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1] + + settlements.push( + handler?.(makeScanEvent({ id: 7 }), { includeAllWorkspaces: true }) as Promise, + handler?.(makeScanEvent({ id: 7 }), { includeAllWorkspaces: true }) as Promise + ) + const [first, second] = await Promise.allSettled(settlements) + + expect(first?.status).toBe('rejected') + expect(second?.status).toBe('fulfilled') + }) + + it('cancels only the invoking renderer scan id', async () => { + scanWorkspaceCleanupMock.mockImplementation((_store, _args, options) => { + return new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(new Error('cancelled')), { + once: true + }) + }) + }) + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handlers = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls) + const event = makeScanEvent({ id: 7 }) + + const scan = handlers['workspaceCleanup:scan']?.(event, { scanId: 'scan-1' }) + expect(handlers['workspaceCleanup:cancelScan']?.(event, 'scan-1')).toBe(true) + await expect(scan).rejects.toThrow('cancelled') + expect( + handlers['workspaceCleanup:cancelScan']?.({ sender: { id: 8 } } as never, 'scan-1') + ).toBe(false) + }) + + it('serves the cached scan snapshot through getCachedScan', async () => { + const snapshot = { scannedAt: NOW, candidates: [], errors: [] } + readScanSnapshotMock.mockResolvedValue(snapshot) + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:getCachedScan')?.[1] + + await expect(handler?.({} as never)).resolves.toBe(snapshot) + expect(readScanSnapshotMock).toHaveBeenCalledWith('/profile-a') + expect(vi.mocked(ipcMain.removeHandler)).toHaveBeenCalledWith('workspaceCleanup:getCachedScan') + }) + + it('routes a validated removal snapshot prune batch through its explicit boundary', async () => { + registerWorkspaceCleanupHandlers(makeEmptyStore()) + const handlers = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls) + const batch = { batchId: 'batch-1' } + const target = { + ...batch, + worktreeId: 'repo-ssh::/remote/feature', + executionHostId: 'ssh:ssh-1' + } + + await handlers['workspaceCleanup:beginRemovalSnapshotPruneBatch']?.({} as never, batch) + await handlers['workspaceCleanup:recordRemovalSnapshotPrune']?.({} as never, target) + await handlers['workspaceCleanup:finishRemovalSnapshotPruneBatch']?.({} as never, batch) + + expect(beginPruneBatchMock).toHaveBeenCalledExactlyOnceWith('/profile-a', batch) + expect(recordPruneMock).toHaveBeenCalledExactlyOnceWith('/profile-a', target) + expect(finishPruneBatchMock).toHaveBeenCalledExactlyOnceWith('/profile-a', batch) + }) +}) diff --git a/src/main/ipc/workspace-cleanup-worktree-listing.ts b/src/main/ipc/workspace-cleanup-worktree-listing.ts new file mode 100644 index 00000000000..dab37caf3f9 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-worktree-listing.ts @@ -0,0 +1,76 @@ +import type { Store } from '../persistence' +import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import type { IGitProvider } from '../providers/types' +import type { Repo } from '../../shared/repo-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import type { + WorkspaceCleanupScanError, + WorkspaceCleanupScanResult +} from '../../shared/workspace-cleanup' +import { + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + createWorkspaceCleanupScanError, + toSafeWorkspaceCleanupRepoScanError, + withWorkspaceCleanupTimeout +} from './workspace-cleanup-scan-primitives' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' + +export async function listCleanupGitWorktrees( + store: Store, + repo: Repo, + repoIsFolder: boolean, + signal?: AbortSignal +): Promise<{ provider: IGitProvider | null; gitWorktrees: GitWorktreeInfo[] }> { + if (repoIsFolder) { + return { + provider: repo.connectionId ? (getSshGitProvider(repo.connectionId) ?? null) : null, + gitWorktrees: [createFolderWorktree(repo)] + } + } + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) ?? null + if (!provider) { + // Why: cleanup should reflect only workspaces Orca can currently inspect. + return { provider: null, gitWorktrees: [] } + } + return { + provider, + gitWorktrees: await withWorkspaceCleanupTimeout( + (signal) => provider.listWorktrees(repo.path, { signal }), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out listing SSH worktrees.', + signal + ) + } + } + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + return { + provider: null, + gitWorktrees: await withWorkspaceCleanupTimeout( + (signal) => listRepoWorktrees(repo, { ...localGitOptions, signal }), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out listing worktrees.', + signal + ) + } +} + +export function handleRepoWorktreeListError(args: { + repo: Repo + targeted: boolean + scannedAt: number + error: unknown + onErrors?: (errors: WorkspaceCleanupScanError[]) => void +}): WorkspaceCleanupScanResult { + const { repo, targeted, scannedAt, error, onErrors } = args + console.error('Workspace cleanup repo scan failed', error) + if (repo.connectionId && !targeted) { + // Why: broad cleanup only shows remote workspaces Orca can inspect now. + // A connected SSH repo that fails mid-scan is omitted, not bannered. + return { scannedAt, candidates: [], errors: [] } + } + const errors = [createWorkspaceCleanupScanError(repo, toSafeWorkspaceCleanupRepoScanError(error))] + onErrors?.(errors) + return { scannedAt, candidates: [], errors } +} diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 96f0ce821f8..b35167906c8 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -17,7 +17,6 @@ const { gitExecFileAsyncMock, getLocalProjectWorktreeGitOptionsMock, getSshGitProviderMock, - getSshPtyProviderMock, listRegisteredPtysMock } = vi.hoisted(() => ({ lstatMock: vi.fn(), @@ -27,7 +26,6 @@ const { gitExecFileAsyncMock: vi.fn(), getLocalProjectWorktreeGitOptionsMock: vi.fn(), getSshGitProviderMock: vi.fn(), - getSshPtyProviderMock: vi.fn(), listRegisteredPtysMock: vi.fn() })) @@ -69,10 +67,16 @@ vi.mock('../memory/pty-registry', () => ({ })) vi.mock('./pty', () => ({ - getSshPtyProvider: getSshPtyProviderMock + getSshPtyProvider: vi.fn() })) -import { registerWorkspaceCleanupHandlers, scanWorkspaceCleanup } from './workspace-cleanup' +// Why: snapshot persistence does real file I/O; covered in workspace-cleanup-snapshot-ipc.test.ts. +vi.mock('../workspace-cleanup-scan-snapshot', () => ({ + persistWorkspaceCleanupScanResult: vi.fn(async () => undefined), + readWorkspaceCleanupScanSnapshot: vi.fn(async () => null) +})) + +import { scanWorkspaceCleanup } from './workspace-cleanup' const NOW = 1_700_000_000_000 const REPO: Repo = { @@ -161,7 +165,6 @@ describe('workspace cleanup scan', () => { gitExecFileAsyncMock.mockReset() getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) getSshGitProviderMock.mockReset() - getSshPtyProviderMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) lstatMock.mockResolvedValue({ mtimeMs: 0 }) @@ -246,11 +249,11 @@ describe('workspace cleanup scan', () => { (event as WorkspaceCleanupScanProgress).candidateMode === 'append' && (event as WorkspaceCleanupScanProgress).candidates.length > 0 ) - expect(candidateProgress).toHaveLength(2) - expect(candidateProgress.every((event) => event.candidates.length === 1)).toBe(true) + expect(candidateProgress.length).toBeLessThanOrEqual(2) const progressWorktreeIds = candidateProgress.flatMap((event) => event.candidates.map((candidate) => candidate.worktreeId) ) + expect(progressWorktreeIds).toHaveLength(2) expect(progressWorktreeIds).toEqual( expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b']) ) @@ -829,61 +832,4 @@ describe('workspace cleanup scan', () => { }) expect(result.candidates[0]).not.toHaveProperty('linkedPR') }) - - it('reports local processes that workspace deletion would kill', async () => { - const localProvider = { - listProcesses: vi.fn().mockResolvedValue([ - { - id: 'repo-1::/repo-feature@@session-1', - cwd: '/repo-feature', - title: 'zsh' - } - ]) - } - registerWorkspaceCleanupHandlers(makeStore(), { - runtime: { - hasTerminalsForWorktree: vi.fn().mockResolvedValue(false) - } as never, - getLocalPtyProvider: () => localProvider as never - }) - - const handler = vi - .mocked(ipcMain.handle) - .mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1] - - await expect(handler?.({} as never, { worktreeId: 'repo-1::/repo-feature' })).resolves.toEqual({ - hasKillableProcesses: true - }) - }) - - it('reports SSH processes inside the remote workspace path', async () => { - getSshPtyProviderMock.mockReturnValue({ - listProcesses: vi.fn().mockResolvedValue([ - { - id: 'remote-session-1', - cwd: '/remote/repo-feature/subdir', - title: 'codex' - } - ]) - }) - registerWorkspaceCleanupHandlers(makeStore(), { - runtime: { - hasTerminalsForWorktree: vi.fn().mockResolvedValue(false) - } as never - }) - - const handler = vi - .mocked(ipcMain.handle) - .mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1] - - await expect( - handler?.({} as never, { - worktreeId: 'repo-ssh::/remote/repo-feature', - connectionId: 'ssh-1', - worktreePath: '/remote/repo-feature' - }) - ).resolves.toEqual({ - hasKillableProcesses: true - }) - }) }) diff --git a/src/main/ipc/workspace-cleanup.ts b/src/main/ipc/workspace-cleanup.ts index 229f4c4177f..e73dd27cc5e 100644 --- a/src/main/ipc/workspace-cleanup.ts +++ b/src/main/ipc/workspace-cleanup.ts @@ -10,9 +10,22 @@ import { type WorkspaceCleanupLocalProcessArgs, type WorkspaceCleanupLocalProcessResult, type WorkspaceCleanupScanArgs, - type WorkspaceCleanupScanResult + type WorkspaceCleanupScanResult, + type WorkspaceCleanupSnapshotPruneBatchArgs, + type WorkspaceCleanupSnapshotPruneRecordArgs } from '../../shared/workspace-cleanup' +import { parseExecutionHostId } from '../../shared/execution-host' import { scanWorkspaceCleanup } from './workspace-cleanup-scan' +import { hasTargetedWorkspaceCleanupScan } from './workspace-cleanup-scan-targets' +import { + persistWorkspaceCleanupScanResult, + readWorkspaceCleanupScanSnapshot +} from '../workspace-cleanup-scan-snapshot' +import { + beginWorkspaceCleanupRemovalSnapshotPruneBatch, + finishWorkspaceCleanupRemovalSnapshotPruneBatch, + recordWorkspaceCleanupRemovalSnapshotPrune +} from '../workspace-cleanup-removal-snapshot-prune' export { scanWorkspaceCleanup } @@ -21,28 +34,109 @@ type WorkspaceCleanupHandlerDeps = { getLocalPtyProvider?: () => IPtyProvider } +// Why: module scope — handler re-registration on a new main window must not +// orphan the previous window's controllers in a discarded map. +const activeScans = new Map() +// Keyed by sender AND scan mode: legacy suggestion-only and full-workspace +// broad scans are separate lanes and must not supersede each other, matching +// the renderer's broad-scan registry. +const broadScanControllersBySenderMode = new Map() + +function getBroadScanModeKey(senderId: number, args: WorkspaceCleanupScanArgs): string { + return `${senderId}\0${args.includeAllWorkspaces === true}` +} + export function registerWorkspaceCleanupHandlers( store: Store, deps: WorkspaceCleanupHandlerDeps = {} ): void { + const snapshotDirectory = store.getProfileStorageDirectory() ipcMain.removeHandler('workspaceCleanup:scan') + ipcMain.removeHandler('workspaceCleanup:cancelScan') + ipcMain.removeHandler('workspaceCleanup:getCachedScan') ipcMain.removeHandler('workspaceCleanup:dismiss') ipcMain.removeHandler('workspaceCleanup:clearDismissals') ipcMain.removeHandler('workspaceCleanup:hasKillableLocalProcesses') + ipcMain.removeHandler('workspaceCleanup:beginRemovalSnapshotPruneBatch') + ipcMain.removeHandler('workspaceCleanup:recordRemovalSnapshotPrune') + ipcMain.removeHandler('workspaceCleanup:finishRemovalSnapshotPruneBatch') ipcMain.handle( 'workspaceCleanup:scan', - (event, args?: WorkspaceCleanupScanArgs): Promise => - scanWorkspaceCleanup(store, args ?? {}, { - onProgress: args?.scanId - ? (progress) => event.sender.send('workspaceCleanup:scanProgress', progress) - : undefined - }) + async (event, args?: WorkspaceCleanupScanArgs): Promise => { + const scanArgs = args ?? {} + const sender = event.sender + const scanKey = getWorkspaceCleanupScanKey(sender.id, scanArgs.scanId) + const controller = new AbortController() + if (scanKey) { + activeScans.set(scanKey, controller) + } + const targeted = hasTargetedWorkspaceCleanupScan(scanArgs) + const broadScanKey = getBroadScanModeKey(sender.id, scanArgs) + if (!targeted) { + // Why: two same-mode broad fleet scans from one renderer can only be a + // refresh race; running both doubles git subprocess and fs load for a + // result the renderer will discard. + broadScanControllersBySenderMode.get(broadScanKey)?.abort() + broadScanControllersBySenderMode.set(broadScanKey, controller) + } + // Why: a window close or reload must stop the fleet scan's git and fs + // work, not merely mute its progress events. + const onSenderDestroyed = (): void => controller.abort() + sender.once('destroyed', onSenderDestroyed) + try { + const result = await scanWorkspaceCleanup(store, scanArgs, { + signal: controller.signal, + onProgress: scanArgs.scanId + ? (progress) => { + if (!sender.isDestroyed()) { + sender.send('workspaceCleanup:scanProgress', progress) + } + } + : undefined + }) + // Focused scans are live-only; persisting each rewrites and fsyncs the + // fleet snapshot. worktreeIds: [] is still targeted — persisting its + // empty result would wipe the fleet cache. + if (!targeted) { + void persistWorkspaceCleanupScanResult(snapshotDirectory, scanArgs, result) + } + return result + } finally { + if (!sender.isDestroyed()) { + sender.removeListener('destroyed', onSenderDestroyed) + } + if (scanKey && activeScans.get(scanKey) === controller) { + activeScans.delete(scanKey) + } + if (!targeted && broadScanControllersBySenderMode.get(broadScanKey) === controller) { + broadScanControllersBySenderMode.delete(broadScanKey) + } + } + } + ) + + ipcMain.handle('workspaceCleanup:cancelScan', (event, scanId: string): boolean => { + const scanKey = getWorkspaceCleanupScanKey(event.sender.id, scanId) + const controller = scanKey ? activeScans.get(scanKey) : undefined + if (!controller || controller.signal.aborted) { + return false + } + controller.abort() + return true + }) + + ipcMain.handle( + 'workspaceCleanup:getCachedScan', + (): Promise => + readWorkspaceCleanupScanSnapshot(snapshotDirectory) ) ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => { - const current = store.getUI().workspaceCleanup?.dismissals ?? {} - const next = { ...current } + const next = { ...store.getUI().workspaceCleanup?.dismissals } + for (const worktreeId of args.removedWorktreeIds ?? []) { + delete next[worktreeId] + } for (const dismissal of args.dismissals ?? []) { if ( dismissal && @@ -69,6 +163,49 @@ export function registerWorkspaceCleanupHandlers( hasKillableProcesses: await hasKillableProcesses(args, deps) }) ) + + ipcMain.handle( + 'workspaceCleanup:beginRemovalSnapshotPruneBatch', + (_event, args: WorkspaceCleanupSnapshotPruneBatchArgs) => { + if (isSnapshotPruneBatchId(args?.batchId)) { + beginWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, args) + } + } + ) + + ipcMain.handle( + 'workspaceCleanup:recordRemovalSnapshotPrune', + (_event, args: WorkspaceCleanupSnapshotPruneRecordArgs) => { + if ( + isSnapshotPruneBatchId(args?.batchId) && + typeof args?.worktreeId === 'string' && + args.worktreeId.length > 0 && + (args.executionHostId === undefined || parseExecutionHostId(args.executionHostId)) + ) { + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, args) + } + } + ) + + ipcMain.handle( + 'workspaceCleanup:finishRemovalSnapshotPruneBatch', + (_event, args: WorkspaceCleanupSnapshotPruneBatchArgs) => { + if (isSnapshotPruneBatchId(args?.batchId)) { + return finishWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, args) + } + return undefined + } + ) +} + +function isSnapshotPruneBatchId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 128 +} + +function getWorkspaceCleanupScanKey(senderId: number, scanId: unknown): string | null { + return typeof scanId === 'string' && scanId.length > 0 && scanId.length <= 128 + ? `${senderId}\0${scanId}` + : null } async function hasKillableProcesses( diff --git a/src/main/ipc/workspace-space.test.ts b/src/main/ipc/workspace-space.test.ts index c6605c73cce..63b118eff19 100644 --- a/src/main/ipc/workspace-space.test.ts +++ b/src/main/ipc/workspace-space.test.ts @@ -11,6 +11,8 @@ const { analyzeWorkspaceSpaceMock, removeHandlerMock, handleMock, + persistAnalysisSnapshotMock, + readAnalysisSnapshotMock, WorkspaceSpaceScanCancelledErrorMock } = vi.hoisted(() => { const handlers = new Map Promise>() @@ -21,6 +23,8 @@ const { handleMock: vi.fn((channel: string, handler: (...args: unknown[]) => Promise) => { handlers.set(channel, handler) }), + persistAnalysisSnapshotMock: vi.fn(), + readAnalysisSnapshotMock: vi.fn(), WorkspaceSpaceScanCancelledErrorMock: class WorkspaceSpaceScanCancelledError extends Error {} } }) @@ -37,6 +41,11 @@ vi.mock('../workspace-space-analysis', () => ({ analyzeWorkspaceSpace: analyzeWorkspaceSpaceMock })) +vi.mock('../workspace-space-analysis-snapshot', () => ({ + persistWorkspaceSpaceAnalysisSnapshot: persistAnalysisSnapshotMock, + readWorkspaceSpaceAnalysisSnapshot: readAnalysisSnapshotMock +})) + import { registerWorkspaceSpaceHandlers } from './workspace-space' function createAnalysis(scannedAt: number): WorkspaceSpaceAnalysis { @@ -65,13 +74,19 @@ function createEvent() { } } +function createStore(): Store { + return { getProfileStorageDirectory: () => '/profile-a' } as Store +} + describe('registerWorkspaceSpaceHandlers', () => { beforeEach(() => { analyzeWorkspaceSpaceMock.mockReset() + persistAnalysisSnapshotMock.mockReset().mockResolvedValue(undefined) + readAnalysisSnapshotMock.mockReset().mockResolvedValue(null) }) it('shares an in-flight analysis request', async () => { - const store = {} as Store + const store = createStore() let resolveFirstScan: (analysis: WorkspaceSpaceAnalysis) => void = () => {} const firstScan = new Promise((resolve) => { resolveFirstScan = resolve @@ -109,7 +124,7 @@ describe('registerWorkspaceSpaceHandlers', () => { }) it('forwards scan progress to the requesting renderer', async () => { - const store = {} as Store + const store = createStore() let onProgress: ((progress: WorkspaceSpaceScanProgress) => void) | undefined analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => { onProgress = options.onProgress @@ -138,8 +153,65 @@ describe('registerWorkspaceSpaceHandlers', () => { expect(event.sender.send).toHaveBeenCalledWith('workspaceSpace:progress', progress) }) + it('batches completed measurements without dropping throttled rows', async () => { + const store = createStore() + let onProgress: ((progress: WorkspaceSpaceScanProgress) => void) | undefined + let resolveScan!: (analysis: WorkspaceSpaceAnalysis) => void + analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => { + onProgress = options.onProgress + return new Promise((resolve) => { + resolveScan = resolve + }) + }) + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + + try { + registerWorkspaceSpaceHandlers(store) + const event = createEvent() + const promise = handlers.get('workspaceSpace:analyze')!(event) + const base = { + scanId: 'scan-1', + state: 'running' as const, + startedAt: 1, + updatedAt: 1, + totalRepoCount: 1, + scannedRepoCount: 0, + totalWorktreeCount: 2, + currentRepoDisplayName: 'orca', + currentWorktreeDisplayName: 'feature' + } + onProgress?.({ ...base, scannedWorktreeCount: 0 }) + onProgress?.({ + ...base, + scannedWorktreeCount: 1, + completedMeasurements: [{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }] + }) + onProgress?.({ + ...base, + scannedWorktreeCount: 2, + completedMeasurements: [{ worktreeId: 'b', status: 'ok', sizeBytes: 20 }] + }) + resolveScan(createAnalysis(1)) + await promise + + expect(event.sender.send).toHaveBeenCalledTimes(2) + expect(event.sender.send).toHaveBeenLastCalledWith( + 'workspaceSpace:progress', + expect.objectContaining({ + scannedWorktreeCount: 2, + completedMeasurements: [ + { worktreeId: 'a', status: 'ok', sizeBytes: 10 }, + { worktreeId: 'b', status: 'ok', sizeBytes: 20 } + ] + }) + ) + } finally { + now.mockRestore() + } + }) + it('cancels the in-flight scan', async () => { - const store = {} as Store + const store = createStore() let signal: AbortSignal | undefined analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => { signal = options.signal @@ -157,12 +229,36 @@ describe('registerWorkspaceSpaceHandlers', () => { }) it('returns a normal cancelled result instead of rejecting expected cancellation', async () => { - const store = {} as Store + const store = createStore() analyzeWorkspaceSpaceMock.mockRejectedValueOnce(new WorkspaceSpaceScanCancelledErrorMock()) registerWorkspaceSpaceHandlers(store) const analyzeHandler = handlers.get('workspaceSpace:analyze') await expect(analyzeHandler!(createEvent())).resolves.toEqual({ ok: false, cancelled: true }) + expect(persistAnalysisSnapshotMock).not.toHaveBeenCalled() + }) + + it('persists the completed analysis snapshot', async () => { + const analysis = createAnalysis(7) + analyzeWorkspaceSpaceMock.mockResolvedValueOnce(analysis) + + registerWorkspaceSpaceHandlers(createStore()) + const analyzeHandler = handlers.get('workspaceSpace:analyze') + + await expect(analyzeHandler!(createEvent())).resolves.toEqual({ ok: true, analysis }) + expect(persistAnalysisSnapshotMock).toHaveBeenCalledWith('/profile-a', analysis) + }) + + it('serves the cached analysis through getCachedAnalysis', async () => { + const cached = createAnalysis(3) + readAnalysisSnapshotMock.mockResolvedValue(cached) + + registerWorkspaceSpaceHandlers(createStore()) + expect(removeHandlerMock).toHaveBeenCalledWith('workspaceSpace:getCachedAnalysis') + const cachedHandler = handlers.get('workspaceSpace:getCachedAnalysis') + + await expect(cachedHandler!()).resolves.toBe(cached) + expect(readAnalysisSnapshotMock).toHaveBeenCalledWith('/profile-a') }) }) diff --git a/src/main/ipc/workspace-space.ts b/src/main/ipc/workspace-space.ts index d4449f3db52..5b10b039b31 100644 --- a/src/main/ipc/workspace-space.ts +++ b/src/main/ipc/workspace-space.ts @@ -1,13 +1,19 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import type { + WorkspaceSpaceAnalysis, WorkspaceSpaceAnalyzeResult, - WorkspaceSpaceScanProgress + WorkspaceSpaceScanProgress, + WorkspaceSpaceWorktreeMeasurement } from '../../shared/workspace-space-types' import { analyzeWorkspaceSpace, WorkspaceSpaceScanCancelledError } from '../workspace-space-analysis' +import { + persistWorkspaceSpaceAnalysisSnapshot, + readWorkspaceSpaceAnalysisSnapshot +} from '../workspace-space-analysis-snapshot' const PROGRESS_EMIT_INTERVAL_MS = 100 @@ -19,9 +25,11 @@ type InFlightWorkspaceSpaceScan = { } export function registerWorkspaceSpaceHandlers(store: Store): void { + const snapshotDirectory = store.getProfileStorageDirectory() let inFlightScan: InFlightWorkspaceSpaceScan | null = null ipcMain.removeHandler('workspaceSpace:cancel') ipcMain.removeHandler('workspaceSpace:analyze') + ipcMain.removeHandler('workspaceSpace:getCachedAnalysis') ipcMain.handle('workspaceSpace:analyze', async (event): Promise => { if (!inFlightScan) { const controller = new AbortController() @@ -39,7 +47,11 @@ export function registerWorkspaceSpaceHandlers(store: Store): void { currentWorktreeDisplayName: null } let lastProgressSentAt = 0 + let pendingMeasurements: WorkspaceSpaceWorktreeMeasurement[] = [] const sendProgress = (progress: WorkspaceSpaceScanProgress): void => { + if (progress.completedMeasurements?.length) { + pendingMeasurements.push(...progress.completedMeasurements) + } // Why: large fleets can report one progress event per worktree; keep // the UI responsive without repainting the full Space page for each row. const now = Date.now() @@ -57,7 +69,12 @@ export function registerWorkspaceSpaceHandlers(store: Store): void { } lastProgressSentAt = now if (!event.sender.isDestroyed()) { - event.sender.send('workspaceSpace:progress', progress) + const completedMeasurements = pendingMeasurements + pendingMeasurements = [] + event.sender.send('workspaceSpace:progress', { + ...progress, + ...(completedMeasurements.length > 0 ? { completedMeasurements } : {}) + }) } } // Why: large worktree fleets require real disk traversal; duplicate @@ -78,7 +95,12 @@ export function registerWorkspaceSpaceHandlers(store: Store): void { sendProgress(progress) } }) - .then((analysis): WorkspaceSpaceAnalyzeResult => ({ ok: true, analysis })) + .then((analysis): WorkspaceSpaceAnalyzeResult => { + // Fire-and-forget: a ~6-minute cold analysis must survive reload/restart, but + // persistence must never delay or fail the reply. + void persistWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, analysis) + return { ok: true, analysis } + }) .catch((error: unknown): WorkspaceSpaceAnalyzeResult => { if (error instanceof WorkspaceSpaceScanCancelledError) { return { ok: false, cancelled: true } @@ -92,6 +114,12 @@ export function registerWorkspaceSpaceHandlers(store: Store): void { return inFlightScan.promise }) + ipcMain.handle( + 'workspaceSpace:getCachedAnalysis', + (): Promise => + readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory) + ) + ipcMain.handle('workspaceSpace:cancel', async (): Promise => { if (!inFlightScan || inFlightScan.controller.signal.aborted) { return false diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index ee9540048cd..692c2ea6d51 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -151,6 +151,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { } } const store = { + getProfileStorageDirectory: vi.fn(() => '/profile-a'), getRepos: vi.fn(), getRepo: vi.fn(), getProjects: vi.fn(), diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 9e2ae8ea8aa..b9ea11b85c8 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -236,6 +236,36 @@ vi.mock('../ports/advertised-url-watcher', () => ({ } })) +const { + pruneCleanupScanSnapshotMock, + pruneCleanupScanSnapshotsMock, + pruneSpaceAnalysisSnapshotMock, + pruneSpaceAnalysisSnapshotsMock +} = vi.hoisted(() => ({ + pruneCleanupScanSnapshotMock: vi.fn().mockResolvedValue(undefined), + pruneCleanupScanSnapshotsMock: vi.fn().mockResolvedValue(undefined), + pruneSpaceAnalysisSnapshotMock: vi.fn().mockResolvedValue(undefined), + pruneSpaceAnalysisSnapshotsMock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('../workspace-cleanup-scan-snapshot', () => ({ + pruneWorkspaceCleanupScanSnapshot: pruneCleanupScanSnapshotMock, + pruneWorkspaceCleanupScanSnapshots: pruneCleanupScanSnapshotsMock +})) + +vi.mock('../workspace-space-analysis-snapshot', () => ({ + pruneWorkspaceSpaceAnalysisSnapshot: pruneSpaceAnalysisSnapshotMock, + pruneWorkspaceSpaceAnalysisSnapshots: pruneSpaceAnalysisSnapshotsMock +})) + +const { recordRemovalSnapshotPruneMock } = vi.hoisted(() => ({ + recordRemovalSnapshotPruneMock: vi.fn() +})) + +vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({ + recordWorkspaceCleanupRemovalSnapshotPrune: recordRemovalSnapshotPruneMock +})) + const { killAllProcessesForWorktreeMock, clearProviderPtyStateMock, @@ -303,6 +333,7 @@ describe('registerWorktreeHandlers', () => { } const ipcEvent = { sender: { id: 1 } } const store = { + getProfileStorageDirectory: vi.fn(() => '/profile-a'), getRepos: vi.fn(), getRepo: vi.fn(), getProjects: vi.fn(), @@ -408,6 +439,11 @@ describe('registerWorktreeHandlers', () => { getSshPtyProviderMock, deleteWorktreeHistoryDirMock, advertisedUrlWatcherForgetWorktreeMock, + pruneCleanupScanSnapshotMock, + pruneCleanupScanSnapshotsMock, + pruneSpaceAnalysisSnapshotMock, + pruneSpaceAnalysisSnapshotsMock, + recordRemovalSnapshotPruneMock, findExistingWorktreeSymlinkPathsMock, removeWorktreeLinkedPathsMock ]) { @@ -2709,6 +2745,10 @@ describe('registerWorktreeHandlers', () => { const metaById: Record> = { 'repo-1::/remote/repo': makeWorktreeMeta({ displayName: 'main', hostId: sshHostId }), 'repo-1::/remote/deleted': makeWorktreeMeta({ displayName: 'deleted', hostId: sshHostId }), + 'repo-1::/remote/deleted-too': makeWorktreeMeta({ + displayName: 'deleted too', + hostId: sshHostId + }), 'repo-1::/remote/other-host': makeWorktreeMeta({ displayName: 'other host', hostId: toSshExecutionHostId('target-b') @@ -2726,18 +2766,33 @@ describe('registerWorktreeHandlers', () => { const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, { repoId: sshRepo.id, executionHostId: sshHostId, - // Only the first is this host's row; the rest must survive an over-broad request. + // Only the first two are this host's rows; the rest must survive an over-broad request. worktreeIds: [ 'repo-1::/remote/deleted', + 'repo-1::/remote/deleted-too', 'repo-1::/remote/other-host', 'repo-2::/remote/other-repo', 'repo-1::/remote/never-persisted' ] }) - expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/remote/deleted'] }) - expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(1) + expect(forgotten).toEqual({ + forgottenWorktreeIds: ['repo-1::/remote/deleted', 'repo-1::/remote/deleted-too'] + }) + expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(2) expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/remote/deleted', sshHostId) + const pruneTargets = [ + { worktreeId: 'repo-1::/remote/deleted', executionHostId: sshHostId }, + { worktreeId: 'repo-1::/remote/deleted-too', executionHostId: sshHostId } + ] + expect(pruneCleanupScanSnapshotsMock).toHaveBeenCalledExactlyOnceWith( + '/profile-a', + pruneTargets + ) + expect(pruneSpaceAnalysisSnapshotsMock).toHaveBeenCalledExactlyOnceWith( + '/profile-a', + pruneTargets + ) // Why: the renderer's suppression memory is session-scoped, so the next launch must not re-list the row. const relisted = (await handlers['worktrees:listKnownForExecutionHost'](null, { @@ -8295,6 +8350,45 @@ describe('registerWorktreeHandlers', () => { }) }) + it('prunes the persisted cleanup and space snapshots on removal', async () => { + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' }) + + // A removed workspace must never resurrect from the cached scan snapshots. + expect(pruneCleanupScanSnapshotMock).toHaveBeenCalledWith( + '/profile-a', + 'repo-1::/workspace/feature-wt', + 'local' + ) + expect(pruneSpaceAnalysisSnapshotMock).toHaveBeenCalledWith( + '/profile-a', + 'repo-1::/workspace/feature-wt', + 'local' + ) + }) + + it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => { + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + snapshotPruneBatchId: 'cleanup-batch-1' + }) + + expect(recordRemovalSnapshotPruneMock).toHaveBeenCalledExactlyOnceWith('/profile-a', { + batchId: 'cleanup-batch-1', + worktreeId: 'repo-1::/workspace/feature-wt', + executionHostId: 'local' + }) + expect(pruneCleanupScanSnapshotMock).not.toHaveBeenCalled() + expect(pruneSpaceAnalysisSnapshotMock).not.toHaveBeenCalled() + }) + it('traces the removal as worktree.remove with a stage sub-span tree', async () => { const records: RedactableSpan[] = [] setActiveSink({ diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 32c85af85a0..f85e0fad8ba 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -17,6 +17,15 @@ import { getProjectGroupSubtreeIds } from '../../shared/project-groups' import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' import { isPathInsideOrEqual, isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' +import { + pruneWorkspaceCleanupScanSnapshot, + pruneWorkspaceCleanupScanSnapshots +} from '../workspace-cleanup-scan-snapshot' +import { + pruneWorkspaceSpaceAnalysisSnapshot, + pruneWorkspaceSpaceAnalysisSnapshots +} from '../workspace-space-analysis-snapshot' +import { recordWorkspaceCleanupRemovalSnapshotPrune } from '../workspace-cleanup-removal-snapshot-prune' import type { OrcaHooks } from '../../shared/orca-yaml-hook-types' import type { Repo } from '../../shared/repo-types' import type { @@ -70,7 +79,7 @@ import { } from '../../shared/worktree/ownership' import { createWorktreeVisibilitySourceMatcher, - normalizeCustomWorktreeVisibilitySources + resolveCustomWorktreeVisibilitySources } from '../../shared/worktree/visibility-sources' import { assertWorktreeCleanForRemoval, @@ -162,6 +171,7 @@ type RemoveWorktreeArgs = { /** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */ allowUnverifiedPtyStop?: boolean skipArchive?: boolean + snapshotPruneBatchId?: string } type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs @@ -326,7 +336,8 @@ function resolveWorktreeRemovalOwnerHostId( function removeWorktreeMetadataAndTransientState( store: Store, worktreeId: string, - hostId?: ExecutionHostId + hostId?: ExecutionHostId, + snapshotPruneBatchId?: string ): void { // Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace. if (hostId) { @@ -341,6 +352,18 @@ function removeWorktreeMetadataAndTransientState( deleteWorktreeHistoryDir(worktreeId) // Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep). pruneWorktreePRRefreshAliases(worktreeId) + // Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. + const snapshotDirectory = store.getProfileStorageDirectory() + if (snapshotPruneBatchId) { + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { + batchId: snapshotPruneBatchId, + worktreeId, + executionHostId: hostId + }) + return + } + void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId) + void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId) } function getProjectHostSetupMetaUpdates( @@ -858,7 +881,7 @@ function buildDetectedGitWorktrees( ) const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( [repo.path, ...liveWorktrees.map((worktree) => worktree.path)], - normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [] + resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults) ) const detected = liveWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` @@ -1010,7 +1033,7 @@ function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktre const worktrees = listFolderWorkspaces(store, repo) const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( [repo.path, ...worktrees.map((worktree) => worktree.path)], - normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [] + resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults) ) return worktrees.map((worktree) => toDetectedWorktree({ @@ -1095,7 +1118,7 @@ function buildDisconnectedDetectedWorktrees( const settings = store.getSettings() const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( [repo.path, ...worktrees.map((worktree) => worktree.path)], - normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [] + resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults) ) const detected = worktrees.map((worktree) => { const meta = store.getWorktreeMeta(worktree.id) @@ -2072,6 +2095,15 @@ export function registerWorktreeHandlers( store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) forgottenWorktreeIds.push(worktreeId) } + if (forgottenWorktreeIds.length > 0) { + const snapshotDirectory = store.getProfileStorageDirectory() + const targets = forgottenWorktreeIds.map((worktreeId) => ({ + worktreeId, + executionHostId: requestedExecutionHostId + })) + void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets) + void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) + } return { forgottenWorktreeIds } } ) @@ -2483,7 +2515,12 @@ export function registerWorktreeHandlers( }) }) await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) }) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) @@ -2595,7 +2632,12 @@ export function registerWorktreeHandlers( invalidateAuthorizedRootsCache() } runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, @@ -2645,7 +2687,12 @@ export function registerWorktreeHandlers( localWorktreeGitOptions ) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, @@ -2682,7 +2729,12 @@ export function registerWorktreeHandlers( invalidateAuthorizedRootsCache() } runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, @@ -2742,7 +2794,12 @@ export function registerWorktreeHandlers( removedPushTarget ) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) return removalResult ?? {} @@ -2841,7 +2898,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) await withWorktreeRemoveStageSpan('metadata_purge', 'remote', async () => { - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) }) notifyWorktreesChanged(mainWindow, repoId) return removalResult ?? {} @@ -2987,7 +3049,12 @@ export function registerWorktreeHandlers( localWorktreeGitOptions ) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, @@ -3024,7 +3091,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => { - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) }) await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { invalidateAuthorizedRootsCache() @@ -3056,7 +3128,7 @@ export function registerWorktreeHandlers( 'worktrees:forgetLocal', async ( _event, - args: Pick + args: Pick ): Promise => { const { repoId } = parseWorktreeId(args.worktreeId) const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId) @@ -3118,7 +3190,12 @@ export function registerWorktreeHandlers( runtime.clearOptimisticReconcileToken(args.worktreeId) // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. - removeWorktreeMetadataAndTransientState(store, args.worktreeId, ownerHost?.id) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + ownerHost?.id, + args.snapshotPruneBatchId + ) // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. invalidateAuthorizedRootsCache() if (ownerHost?.id) { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b92ed0c86c0..cdfd76df8a6 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -37,6 +37,7 @@ import { SshConnectionStore } from './ssh/ssh-connection-store' import { setSourceControlActionDefault } from '../shared/source-control-ai-actions' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { closeTerminalTabInWorkspaceSession } from '../shared/workspace-session-terminal-tab-close' +import { createDefaultWorkspaceCleanupBrowseState } from '../shared/workspace-cleanup-browse-state' // Shared mutable state so the electron mock can reference a per-test directory const testState = { dir: '' } @@ -4853,7 +4854,94 @@ describe('Store', () => { expect(updated!.externalWorktreeVisibilityLegacy).toBe(true) }) - it('keeps rollback agent visibility writes authoritative for both built-in sources', async () => { + it('migrates implicit legacy visibility to an explicit override before defaulting global hide', async () => { + const persisted = getDefaultPersistedState(testState.dir) + delete persisted.settings.worktreeVisibilityDefaults + persisted.repos = [ + makeRepo({ + id: 'legacy', + kind: 'git', + externalWorktreeVisibility: undefined, + externalWorktreeVisibilityLegacy: undefined + }), + makeRepo({ id: 'explicit', externalWorktreeVisibility: 'hide' }), + makeRepo({ + id: 'inherited', + externalWorktreeVisibility: undefined, + externalWorktreeVisibilityLegacy: false + }) + ] + writeDataFile(persisted) + + const store = await createStore() + + expect(store.getSettings().worktreeVisibilityDefaults).toEqual({ external: 'hide' }) + expect(store.getRepo('legacy')).toMatchObject({ + externalWorktreeVisibility: 'show', + externalWorktreeVisibilityLegacy: true + }) + expect(store.getRepo('explicit')?.externalWorktreeVisibility).toBe('hide') + expect(store.getRepo('inherited')?.externalWorktreeVisibility).toBeUndefined() + + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('legacy')).toMatchObject({ + externalWorktreeVisibility: 'show', + externalWorktreeVisibilityLegacy: true + }) + expect(reloaded.getRepo('inherited')?.externalWorktreeVisibility).toBeUndefined() + }) + + it('merges visibility-default patches so future source defaults survive older controls', async () => { + const store = await createStore() + store.updateSettings({ + worktreeVisibilityDefaults: { + external: 'show', + futureSource: 'hide' + } as GlobalSettings['worktreeVisibilityDefaults'] + }) + + store.updateSettings({ worktreeVisibilityDefaults: { external: 'hide' } }) + + expect(store.getSettings().worktreeVisibilityDefaults).toEqual({ + external: 'hide', + futureSource: 'hide' + }) + }) + + it('normalizes and persists global worktree source defaults', async () => { + const store = await createStore() + + store.updateSettings({ + worktreeVisibilityDefaults: { + external: 'show', + customSources: [ + { id: 'team', rootPath: ' /srv/team-worktrees ' }, + { id: 'invalid', rootPath: '../relative' } + ], + sourcePreferences: { + builtIn: { claude: 'show', gsd: 'hide' }, + custom: { team: 'show' } + } + } + }) + + expect(store.getSettings().worktreeVisibilityDefaults).toEqual({ + external: 'show', + customSources: [{ id: 'team', rootPath: '/srv/team-worktrees' }], + sourcePreferences: { + builtIn: { claude: 'show', gsd: 'hide' }, + custom: { team: 'show' } + } + }) + store.flush() + const reloaded = await createStore() + expect(reloaded.getSettings().worktreeVisibilityDefaults).toEqual( + store.getSettings().worktreeVisibilityDefaults + ) + }) + + it('persists agent worktree visibility independently from external visibility', async () => { const store = await createStore() store.addRepo(makeRepo({ externalWorktreeVisibility: 'hide' })) @@ -4908,6 +4996,25 @@ describe('Store', () => { ]) }) + it('clears an explicit external-worktree override without stamping an inherited default', async () => { + const store = await createStore() + store.addRepo( + makeRepo({ + externalWorktreeVisibility: 'show', + externalWorktreeVisibilityLegacy: true + }) + ) + + const updated = store.updateRepo('r1', { externalWorktreeVisibility: null }) + + expect(updated?.externalWorktreeVisibility).toBeUndefined() + expect(updated?.externalWorktreeVisibilityLegacy).toBe(false) + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')?.externalWorktreeVisibility).toBeUndefined() + expect(reloaded.getRepo('r1')?.externalWorktreeVisibilityLegacy).toBe(false) + }) + it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => { const store = await createStore() store.addRepo( @@ -7129,6 +7236,29 @@ describe('Store', () => { }) }) + it('updateUI preserves browse state when a legacy peer publishes dismissals only', async () => { + const store = await createStore() + const browse = createDefaultWorkspaceCleanupBrowseState() + browse.filters.query = 'stale' + + store.updateUI({ workspaceCleanup: { dismissals: {}, browse } }) + store.updateUI({ + workspaceCleanup: { + dismissals: { + 'wt-1': { + worktreeId: 'wt-1', + dismissedAt: 1700000000000, + fingerprint: 'fp-1', + classifierVersion: 2 + } + } + } + }) + + expect(store.getUI().workspaceCleanup?.browse).toEqual(browse) + expect(store.getUI().workspaceCleanup?.dismissals).toHaveProperty('wt-1') + }) + it('updateUI merges contextual tour seen ids instead of replacing stale snapshots', async () => { const store = await createStore() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index b01a298d62f..99829912f2e 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -186,6 +186,7 @@ import { } from '../shared/cross-platform-path' import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' import { normalizeTaskProviderSettings } from '../shared/task-providers' +import { mergeWorkspaceCleanupUIState } from '../shared/workspace-cleanup-ui-state' import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings' import { addMobilePairingCustomAddress, @@ -226,6 +227,10 @@ import { import { clampMarkdownTocPanelWidth } from '../shared/markdown-toc-panel-width' import { clampCombinedDiffFileTreeWidth } from '../shared/combined-diff-file-tree-width' import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree/ownership' +import { + migrateExternalWorktreeVisibilityDefaults, + normalizeWorktreeVisibilityDefaults +} from '../shared/external-worktree-visibility' import { sanitizeRepoIcon } from '../shared/repo-icon' import { normalizeRepoBadgeColor } from '../shared/repo-badge-color' import { @@ -3215,6 +3220,13 @@ export class Store { // Merge with defaults in case new fields were added const homeDir = homedir() const defaults = getDefaultPersistedState(homeDir) + const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults( + Array.isArray(parsed.repos) ? parsed.repos : [], + parsed.settings?.worktreeVisibilityDefaults + ) + if (migratedExternalVisibility.changed) { + this.loadNeedsSave = true + } const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) if (migratedTerminalScrollback.needsSave) { this.loadNeedsSave = true @@ -3484,6 +3496,7 @@ export class Store { parsed.featureInteractionTelemetryBuckets ), projectGroups: normalizedProjectGroups, + repos: migratedExternalVisibility.repos, folderWorkspaces: normalizeFolderWorkspaces( parsed.folderWorkspaces, normalizedProjectGroups @@ -3502,6 +3515,7 @@ export class Store { ...defaults.settings, // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. ...stripRetiredGlobalSettings(parsed.settings), + worktreeVisibilityDefaults: migratedExternalVisibility.defaults, prBotAuthorOverrides: normalizePRBotAuthorOverrides( parsed.settings?.prBotAuthorOverrides ), @@ -4313,6 +4327,10 @@ export class Store { // ── Repos ────────────────────────────────────────────────────────── + getProfileStorageDirectory(): string { + return dirname(this.dataFile) + } + getRepos(): Repo[] { return this.state.repos.map((repo) => this.hydrateRepo(repo)) } @@ -5002,11 +5020,9 @@ export class Store { | 'symlinkPaths' | 'issueSourcePreference' | 'forkSyncMode' - | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' - | 'agentWorktreeVisibility' | 'customWorktreeVisibilitySources' | 'worktreeVisibilitySourcePreferences' | 'projectGroupId' @@ -5014,6 +5030,8 @@ export class Store { | 'projectHostSetupMethod' > > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null }, @@ -5075,6 +5093,22 @@ export class Store { delete repo.worktreeBasePath delete sanitizedUpdates.worktreeBasePath } + if ( + 'externalWorktreeVisibility' in sanitizedUpdates && + (sanitizedUpdates.externalWorktreeVisibility === undefined || + sanitizedUpdates.externalWorktreeVisibility === null) + ) { + delete repo.externalWorktreeVisibility + repo.externalWorktreeVisibilityLegacy = false + delete sanitizedUpdates.externalWorktreeVisibility + } + if ( + 'agentWorktreeVisibility' in sanitizedUpdates && + sanitizedUpdates.agentWorktreeVisibility === null + ) { + delete repo.agentWorktreeVisibility + delete sanitizedUpdates.agentWorktreeVisibility + } if ( 'externalWorktreeVisibility' in sanitizedUpdates && repo.externalWorktreeVisibilityLegacy === undefined @@ -5955,6 +5989,14 @@ export class Store { if ('disabledTuiAgents' in updates) { sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) } + if ('worktreeVisibilityDefaults' in updates) { + sanitizedUpdates.worktreeVisibilityDefaults = { + ...this.state.settings.worktreeVisibilityDefaults, + ...(normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults) ?? { + external: 'hide' + }) + } + } if ('agentDefaultArgs' in updates) { sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs) sanitizedUpdates.agentYoloDefaultsMigrated = true @@ -6219,6 +6261,10 @@ export class Store { const nextUI = { ...currentUI, ...durableUpdates, + workspaceCleanup: mergeWorkspaceCleanupUIState( + currentUI.workspaceCleanup, + durableUpdates.workspaceCleanup + ), groupBy: durableUpdates.groupBy ? normalizeGroupBy(durableUpdates.groupBy) : normalizeGroupBy(this.state.ui?.groupBy), diff --git a/src/main/providers/filesystem-provider-contract.ts b/src/main/providers/filesystem-provider-contract.ts index ab467a7d89b..942cb93f090 100644 --- a/src/main/providers/filesystem-provider-contract.ts +++ b/src/main/providers/filesystem-provider-contract.ts @@ -19,9 +19,14 @@ export type FileReadResult = { mimeType?: string } +export type FileReadLimits = { + maxBinaryBytes?: number + maxTextBytes?: number +} + export type IFilesystemProvider = { readDir(dirPath: string): Promise - readFile(filePath: string): Promise + readFile(filePath: string, limits?: FileReadLimits): Promise readTerminalArtifact?( filePath: string, options: TerminalArtifactAccessOptions diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index eedcb87abfc..a0cd84fc478 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -70,6 +70,7 @@ vi.mock('../pty-descendant-termination', () => ({ const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' +const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe' vi.mock('./windows-powershell-executable', () => ({ resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, @@ -1371,7 +1372,13 @@ describe('LocalPtyProvider', () => { const originalProgramFiles = process.env.ProgramFiles Object.defineProperty(process, 'platform', { value: 'win32' }) process.env.ProgramFiles = 'C:\\Program Files' - provider.configure({ getWindowsShell: () => 'git-bash' }) + provider.configure({ + getWindowsShell: () => 'git-bash', + buildSpawnEnv: (_id, env) => ({ + ...env, + ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT + }) + }) try { await provider.spawn({ @@ -1392,12 +1399,52 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\Git\\bin\\bash.exe', - ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], + [ + '-c', + expect.stringMatching( + /^chcp\.com 65001 >\/dev\/null 2>&1; exec "\$BASH" --rcfile '.*shell-ready\/bash\/rcfile' -i$/ + ) + ], expect.objectContaining({ cwd: 'C:\\Users\\jin\\repo', env: expect.objectContaining({ CHERE_INVOKING: '1', - PYTHONUTF8: '1' + PYTHONUTF8: '1', + ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT + }) + }) + ) + }) + + it('runs the Codex preflight once in the cmd.exe startup chain', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + provider.configure({ + getWindowsShell: () => 'cmd.exe', + buildSpawnEnv: (_id, env) => ({ + ...env, + ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT + }) + }) + + try { + await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'cmd.exe', + [ + '/K', + 'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1' + ], + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT, + ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE: '"' }) }) ) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 4a852017f6a..55d8776d915 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -1,7 +1,10 @@ /* eslint-disable max-lines -- Why: splitting spawn() would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit) with no cleaner ownership seam. */ import { basename, delimiter, win32 as pathWin32 } from 'node:path' import { randomUUID } from 'node:crypto' -import { resolveWindowsShellLaunchArgs } from './windows-shell-args' +import { + ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, + resolveWindowsShellLaunchArgs +} from './windows-shell-args' import { resolveEffectiveWindowsPowerShell, shouldProbeWindowsPowerShellAvailability, @@ -777,6 +780,30 @@ export class LocalPtyProvider implements IPtyProvider { delete finalEnv.CODEX_HOME delete finalEnv.ORCA_CODEX_HOME } + + const shellBasename = pathWin32.basename(shellPath).toLowerCase() + const codexLaunchPreflightCommand = finalEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + if ( + codexLaunchPreflightCommand && + (shellBasename === 'cmd.exe' || isWindowsGitBashShellPath(shellPath)) + ) { + if (shellBasename === 'cmd.exe') { + // Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead. + finalEnv[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' + } + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + cwd, + defaultCwd, + launchWslContext, + args.command, + codexLaunchPreflightCommand + ) + shellArgs = resolved.shellArgs + effectiveCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true + } } seedPowerlevel10kWizardEnv(finalEnv, { envToDelete: args.envToDelete }) if ( diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts index baaaed69406..dd3e004b362 100644 --- a/src/main/providers/ssh-filesystem-provider-stream.test.ts +++ b/src/main/providers/ssh-filesystem-provider-stream.test.ts @@ -155,6 +155,21 @@ describe('SshFilesystemProvider readFile streaming', () => { expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 1 }) }) + it('applies a caller binary cap before allocating the stream buffer', async () => { + mux.request.mockResolvedValue({ + streamId: 2, + totalSize: 2, + isBinary: true, + chunkEncoding: 'base64', + resultEncoding: 'base64' + }) + + await expect(provider.readFile('/home/x.bin', { maxBinaryBytes: 1 })).rejects.toThrow( + /exceeds client cap/i + ) + expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 2 }) + }) + it('rejects on fs.streamError notification', async () => { const totalSize = 1024 mux.request.mockImplementation(async () => { diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 7fa2deb85b9..6adda586065 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -16,6 +16,7 @@ import { } from './ssh-filesystem-provider-watch' import type { IFilesystemProvider, + FileReadLimits, FileStat, FileReadResult, FileUploadSession, @@ -88,14 +89,14 @@ export class SshFilesystemProvider implements IFilesystemProvider { return (await this.mux.request('fs.readDir', { dirPath })) as DirEntry[] } - async readFile(filePath: string): Promise { + async readFile(filePath: string, limits?: FileReadLimits): Promise { // Why: streaming is the default path so previews above the legacy single- // frame budget (~12 MB after base64) don't hit MAX_MESSAGE_SIZE. Old relays // that don't implement fs.readFileStream surface as MethodNotFound; we fall // back to the legacy single-shot fs.readFile (which retains the old 10 MB // cap on those hosts). try { - return await readFileViaStream(this.mux, filePath) + return await readFileViaStream(this.mux, filePath, limits) } catch (err) { if (isMethodNotFoundError(err)) { if (!this.loggedStreamFallback) { diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 23c36859a1f..aae7266ecaa 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -18,6 +18,7 @@ export type { // ─── Filesystem Provider ──────────────────────────────────────────── export type { + FileReadLimits, FileReadResult, FileStat, FileUploadSession, diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 534bfc90085..f0e2e1be4b3 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -9,6 +9,10 @@ import { import { resolveSetupRunnerCommand } from '../../shared/setup-runner-command' import { resolveWindowsShellLaunchArgs } from './windows-shell-args' +const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe' +const CMD_CODEX_LAUNCH_PREFLIGHT = + 'if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1' + function expectedWslArgs(linuxCwd: string, distro?: string): string[] { const command = `cd '${linuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && ${buildWslInteractiveLoginShellCommand()}` const shellArgs = ['--', 'sh', '-c', escapeWslShCommandForWindows(command)] @@ -24,6 +28,14 @@ function expectedPowerShellRestoreCwdCommand(cwdLiteral: string): string { return `try { Set-Location -LiteralPath ${cwdLiteral} -ErrorAction Stop } catch { Write-Warning "Failed to restore working directory: $_" }` } +function getGitBashRcfilePath(command: string): string { + const rcfilePath = command.match(/--rcfile '([^']+)'/)?.[1] + if (!rcfilePath) { + throw new Error(`Git Bash launch command has no rcfile: ${command}`) + } + return rcfilePath +} + describe('resolveWindowsShellLaunchArgs', () => { let previousUserDataPath: string | undefined let userDataPath: string @@ -44,8 +56,15 @@ describe('resolveWindowsShellLaunchArgs', () => { }) it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => { - const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice') - expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) + const result = resolveWindowsShellLaunchArgs( + 'cmd.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + undefined, + CODEX_LAUNCH_PREFLIGHT + ) + expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`]) expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() expect(result.effectiveCwd).toBe('C:\\Users\\alice') expect(result.validationCwd).toBe('C:\\Users\\alice') @@ -57,9 +76,13 @@ describe('resolveWindowsShellLaunchArgs', () => { 'C:\\Users\\alice', 'C:\\Users\\alice', undefined, - 'codex --no-alt-screen' + 'codex --no-alt-screen', + CODEX_LAUNCH_PREFLIGHT ) - expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul & codex --no-alt-screen']) + expect(result.shellArgs).toEqual([ + '/K', + `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT} & codex --no-alt-screen` + ]) expect(result.startupCommandDeliveredInShellArgs).toBe(true) }) @@ -83,9 +106,10 @@ describe('resolveWindowsShellLaunchArgs', () => { 'C:\\Users\\alice', 'C:\\Users\\alice', undefined, - `codex ${'x'.repeat(7000)}` + `codex ${'x'.repeat(7000)}`, + CODEX_LAUNCH_PREFLIGHT ) - expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) + expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`]) expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() }) @@ -216,11 +240,32 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(decodePowerShellCommand(result)).toContain(expectedPowerShellRestoreCwdCommand("'C:\\'")) }) + it('does not change PowerShell args when the cmd/Git Bash preflight is provided', () => { + const baseline = resolveWindowsShellLaunchArgs( + 'powershell.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice' + ) + const withWindowsPreflight = resolveWindowsShellLaunchArgs( + 'powershell.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + undefined, + CODEX_LAUNCH_PREFLIGHT + ) + + expect(withWindowsPreflight).toEqual(baseline) + }) + it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => { const result = resolveWindowsShellLaunchArgs( 'C:\\Program Files\\Git\\bin\\bash.exe', '/c', - 'C:\\Users\\alice' + 'C:\\Users\\alice', + undefined, + undefined, + CODEX_LAUNCH_PREFLIGHT ) // Why: Git Bash inherits the ConPTY OEM code page (CP437), so a byte-writing @@ -231,7 +276,8 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.shellArgs[0]).toBe('-c') const bashCommand = result.shellArgs[1] expect(bashCommand).toContain('chcp.com 65001') - expect(bashCommand).toMatch(/exec "\$BASH" --login -i$/) + expect(bashCommand).toContain('--rcfile') + expect(bashCommand).toMatch(/exec "\$BASH" .* -i$/) // The UTF-8 switch must run before the login shell is exec'd. expect(bashCommand.indexOf('chcp.com')).toBeLessThan(bashCommand.indexOf('exec')) // Must stay fail-open: `;` (not `&&`) so a missing chcp.com can't abort the @@ -239,6 +285,52 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(bashCommand).not.toContain('&&') expect(result.effectiveCwd).toBe('C:\\') expect(result.validationCwd).toBe('C:\\') + + const bashRcfile = readFileSync(getGitBashRcfilePath(bashCommand), 'utf8') + expect(bashRcfile).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex') + expect(bashRcfile).not.toContain(CODEX_LAUNCH_PREFLIGHT) + }) + + it('keeps Git Bash startup commands on stdin delivery with the preflight wrapper', () => { + const result = resolveWindowsShellLaunchArgs( + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + 'codex --no-alt-screen', + CODEX_LAUNCH_PREFLIGHT + ) + + expect(result.shellArgs[1]).toContain('chcp.com 65001') + expect(result.shellArgs[1]).toContain('--rcfile') + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() + }) + + it('quotes a spaced preflight path through each shell environment', () => { + const cmd = resolveWindowsShellLaunchArgs( + 'cmd.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + undefined, + CODEX_LAUNCH_PREFLIGHT + ) + const gitBash = resolveWindowsShellLaunchArgs( + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + undefined, + CODEX_LAUNCH_PREFLIGHT + ) + + expect(cmd.shellArgs[1]).toContain( + 'call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%' + ) + expect(cmd.shellArgs[1]).not.toContain('"') + expect(cmd.shellArgs[1]).not.toContain(CODEX_LAUNCH_PREFLIGHT) + const rcfilePath = getGitBashRcfilePath(gitBash.shellArgs[1]) + expect(readFileSync(rcfilePath, 'utf8')).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}"') }) it('does not apply Git Bash launch args to unrelated bash.exe paths', () => { @@ -254,13 +346,22 @@ describe('resolveWindowsShellLaunchArgs', () => { }) it('translates Windows cwd to /mnt//... for wsl.exe', () => { - const result = resolveWindowsShellLaunchArgs( + const baseline = resolveWindowsShellLaunchArgs( 'wsl.exe', 'C:\\Users\\alice\\code', 'C:\\Users\\alice', undefined, 'codex' ) + const result = resolveWindowsShellLaunchArgs( + 'wsl.exe', + 'C:\\Users\\alice\\code', + 'C:\\Users\\alice', + undefined, + 'codex', + CODEX_LAUNCH_PREFLIGHT + ) + expect(result).toEqual(baseline) expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code')) expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() // Why: WSL cannot cd into a Windows path, so node-pty must start from the diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 772ef8d9f05..1cbf8a4b648 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -6,7 +6,7 @@ import { escapeWslShCommandForWindows, quotePosixShell } from '../../shared/wsl-login-shell-command' -import { ensureShellReadyWrappersAt } from './local-pty-shell-ready' +import { ensureShellReadyWrappersAt, getMarkerlessShellLaunchConfig } from './local-pty-shell-ready' import { encodePowerShellCommand, getPowerShellOsc133Bootstrap @@ -17,6 +17,8 @@ const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' +export const ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV = 'ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE' +const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %${ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV}%%ORCA_CODEX_LAUNCH_PREFLIGHT%%${ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV}% agent hooks prepare-codex > nul 2>&1` // Why: Git for Windows' bash inherits the ConPTY console's OEM code page // (CP437), so a TUI that writes UTF-8 bytes straight to the console — agents // like Claude Code use WriteFile, not WriteConsoleW — renders as mojibake @@ -25,6 +27,22 @@ const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' // `&&`) keeps startup working even if chcp.com is missing. const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' +function getGitBashLaunchCommand(codexLaunchPreflightCommand?: string): string { + if (!codexLaunchPreflightCommand) { + return GIT_BASH_UTF8_LOGIN_COMMAND + } + + ensureShellReadyWrappersAt() + const wrapperArgs = getMarkerlessShellLaunchConfig('bash').args + if (!wrapperArgs) { + return GIT_BASH_UTF8_LOGIN_COMMAND + } + const bashArgs = [...wrapperArgs, '-i'] + .map((arg) => (arg.startsWith('-') ? arg : quotePosixShell(arg.replace(/\\/g, '/')))) + .join(' ') + return `chcp.com 65001 >/dev/null 2>&1; exec "$BASH" ${bashArgs}` +} + /** Result of resolving a Windows shell to its launch args + effective cwd. * * Why this module exists: both the in-process LocalPtyProvider and the @@ -159,20 +177,21 @@ export function resolveWindowsShellLaunchArgs( cwd: string, defaultCwd: string, wslContext?: WindowsShellWslContext, - startupCommand?: string + startupCommand?: string, + codexLaunchPreflightCommand?: string ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() const nativeCwd = normalizeWindowsTerminalCwd(cwd) if (shellBasename === 'cmd.exe') { const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) + const startupCommands = [ + CMD_UTF8_SETUP_COMMAND, + ...(codexLaunchPreflightCommand ? [CMD_CODEX_LAUNCH_PREFLIGHT] : []), + ...(shellArgStartupCommand ? [shellArgStartupCommand] : []) + ] return { - shellArgs: [ - '/K', - shellArgStartupCommand - ? `${CMD_UTF8_SETUP_COMMAND} & ${shellArgStartupCommand}` - : CMD_UTF8_SETUP_COMMAND - ], + shellArgs: ['/K', startupCommands.join(' & ')], ...(shellArgStartupCommand ? { startupCommandDeliveredInShellArgs: true } : {}), effectiveCwd: nativeCwd, validationCwd: nativeCwd @@ -195,7 +214,7 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: ['-c', GIT_BASH_UTF8_LOGIN_COMMAND], + shellArgs: ['-c', getGitBashLaunchCommand(codexLaunchPreflightCommand)], effectiveCwd: nativeCwd, validationCwd: nativeCwd } diff --git a/src/main/providers/windows-shell-preflight-runtime.windows.test.ts b/src/main/providers/windows-shell-preflight-runtime.windows.test.ts new file mode 100644 index 00000000000..bae27db3a59 --- /dev/null +++ b/src/main/providers/windows-shell-preflight-runtime.windows.test.ts @@ -0,0 +1,205 @@ +import { + copyFileSync, + existsSync, + linkSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveGitBashPath } from '../git-bash' +import { + ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, + resolveWindowsShellLaunchArgs +} from './windows-shell-args' + +const describeWindows = process.platform === 'win32' ? describe : describe.skip +const tempDirs: string[] = [] + +function makeTempDir(): string { + const root = mkdtempSync(join(tmpdir(), 'orca shell preflight ')) + tempDirs.push(root) + return root +} + +function linkNodeExecutable(destination: string): void { + try { + linkSync(process.execPath, destination) + } catch { + copyFileSync(process.execPath, destination) + } +} + +function writeFailingPreflight(root: string): string { + const executable = join(root, 'orca preflight.exe') + linkNodeExecutable(executable) + writeFileSync( + join(root, 'agent'), + [ + "const { writeFileSync } = require('node:fs')", + "if (process.argv.slice(2).join(' ') !== 'hooks prepare-codex') process.exit(2)", + "writeFileSync(process.env.ORCA_PREFLIGHT_MARKER, 'ran')", + 'process.exit(7)' + ].join('\n') + ) + return executable +} + +function withPathEntry(env: NodeJS.ProcessEnv, entry: string): NodeJS.ProcessEnv { + const result = { ...env } + const pathKey = Object.keys(result).find((key) => key.toLowerCase() === 'path') + const inheritedPath = pathKey ? result[pathKey] : '' + if (pathKey) { + delete result[pathKey] + } + result.PATH = `${entry};${inheritedPath ?? ''}` + return result +} + +async function runPty(options: { + shellPath: string + shellArgs: string[] + cwd: string + env: NodeJS.ProcessEnv + input?: string + timeoutMs?: number +}): Promise { + const proc = pty.spawn(options.shellPath, options.shellArgs, { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd: options.cwd, + env: options.env + }) + let output = '' + proc.onData((data) => { + output += data + }) + const exitPromise = new Promise((resolve) => { + proc.onExit(({ exitCode }) => resolve(exitCode)) + }) + let timeout: ReturnType | undefined + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`timed out waiting for Windows shell PTY:\n${output}`)), + options.timeoutMs ?? 10_000 + ) + }) + + try { + if (options.input) { + proc.write(options.input.replaceAll('\n', '\r')) + } + const exitCode = await Promise.race([exitPromise, timeoutPromise]) + expect(exitCode, output).toBe(0) + return output + } finally { + if (timeout) { + clearTimeout(timeout) + } + try { + proc.kill() + } catch { + // The PTY may already have exited. + } + } +} + +afterEach(() => { + for (const root of tempDirs.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describeWindows('Windows Codex shell preflight runtime', () => { + it('runs a spaced executable through cmd.exe and continues after failure', async () => { + const root = makeTempDir() + const preflight = writeFailingPreflight(root) + const preflightMarker = join(root, 'cmd-preflight-ran') + const startupMarker = join(root, 'cmd-started') + const resolved = resolveWindowsShellLaunchArgs( + 'cmd.exe', + root, + root, + undefined, + 'echo launched>cmd-started & exit /b 0', + preflight + ) + + await runPty({ + shellPath: 'cmd.exe', + shellArgs: resolved.shellArgs, + cwd: root, + env: { + ...process.env, + ORCA_CODEX_LAUNCH_PREFLIGHT: preflight, + [ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV]: '"', + ORCA_PREFLIGHT_MARKER: preflightMarker + } + }) + + expect(readFileSync(preflightMarker, 'utf8')).toBe('ran') + expect(readFileSync(startupMarker, 'utf8').trim()).toBe('launched') + }) + + it('runs the typed-Codex wrapper through Git Bash without MSYS switch rewriting', async () => { + const gitBash = resolveGitBashPath() + expect(gitBash).not.toBeNull() + if (!gitBash) { + return + } + + const root = makeTempDir() + const preflight = writeFailingPreflight(root) + const codexExecutable = join(root, 'codex.exe') + linkNodeExecutable(codexExecutable) + const preflightMarker = join(root, 'git-bash-preflight-ran') + const codexMarker = join(root, 'git-bash-codex-ran') + const previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = join(root, 'user data') + + try { + const resolved = resolveWindowsShellLaunchArgs( + gitBash, + root, + root, + undefined, + undefined, + preflight + ) + await runPty({ + shellPath: gitBash, + shellArgs: resolved.shellArgs, + cwd: root, + env: { + ...withPathEntry(process.env, root), + CHERE_INVOKING: '1', + HOME: root, + ORCA_CODEX_LAUNCH_PREFLIGHT: preflight, + ORCA_PREFLIGHT_MARKER: preflightMarker, + ORCA_CODEX_MARKER: codexMarker, + TERM: 'xterm-256color' + }, + input: + "codex -e \"require('node:fs').writeFileSync(process.env.ORCA_CODEX_MARKER,'ran')\"\nexit\n", + // Paired "Windows low spec" QA measured 12.7–15.8s across four runs: Git Bash + // cold-starts two large Node executables for AV scanning, so allow 25s without + // inflating the faster cmd.exe budget. + timeoutMs: 25_000 + }) + } finally { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + } + + expect(existsSync(preflightMarker)).toBe(true) + expect(readFileSync(codexMarker, 'utf8')).toBe('ran') + }) +}) diff --git a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts new file mode 100644 index 00000000000..ce7e8c4b6ee --- /dev/null +++ b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts @@ -0,0 +1,127 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path' +import { getDaemonBashShellReadyRcfileContent } from '../daemon/shell-ready' +import { resolveCodexShellLaunchPreflightCommand } from './codex-shell-launch-preflight' + +// Why (STA-4270): the generated rcfile sources /etc/profile and the user's profile +// *before* it defines the codex() wrapper, so a profile PATH prepend decides what an +// unqualified preflight command resolves to. This drives the real generated rcfile +// through a real bash and uses filesystem markers — not scraped output — as the oracle. + +const roots: string[] = [] +const bashAvailable = process.platform !== 'win32' && existsSync('/bin/bash') + +type Fixture = { + root: string + resourcesPath: string + intendedMarker: string + hijackMarker: string + codexMarker: string + homePath: string +} + +function writeStub(path: string, markerPath: string): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, `#!/bin/sh\nprintf '%s\\n' "$*" > ${JSON.stringify(markerPath)}\n`) + chmodSync(path, 0o755) +} + +function buildFixture(): Fixture { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-profile-path-')) + roots.push(root) + const resourcesPath = join(root, 'resources') + const hijackDir = join(root, 'hijack') + const codexDir = join(root, 'codex-bin') + const homePath = join(root, 'home') + const intendedMarker = join(root, 'intended-ran') + const hijackMarker = join(root, 'hijack-ran') + const codexMarker = join(root, 'codex-ran') + mkdirSync(hijackDir, { recursive: true }) + mkdirSync(codexDir, { recursive: true }) + mkdirSync(homePath, { recursive: true }) + + // The CLI Orca ships, at the absolute path Orca controls. + writeStub(getBundledLauncherPath(process.platform, resourcesPath) as string, intendedMarker) + // The impostor a user's own bin directory could hold under every CLI name Orca uses. + for (const name of ['orca', 'orca-ide', 'orca-dev']) { + writeStub(join(hijackDir, name), hijackMarker) + } + writeStub(join(codexDir, 'codex'), codexMarker) + + // Why: /etc/profile runs first and macOS path_helper rebuilds PATH from /etc/paths, + // so the profile must re-prepend both dirs to model a real user's dotfile winning. + writeFileSync( + join(homePath, '.bash_profile'), + `export PATH=${JSON.stringify(codexDir)}:"$PATH"\nexport PATH=${JSON.stringify(hijackDir)}:"$PATH"\n` + ) + return { root, resourcesPath, intendedMarker, hijackMarker, codexMarker, homePath } +} + +function launchCodexThroughRcfile(fixture: Fixture, preflightValue: string): void { + const rcfilePath = join(fixture.root, 'rcfile') + writeFileSync(rcfilePath, getDaemonBashShellReadyRcfileContent(), 'utf8') + execFileSync('/bin/bash', ['--rcfile', rcfilePath, '-i', '-c', 'codex --version'], { + stdio: 'ignore', + env: { + HOME: fixture.homePath, + // Why: the hijack dir is deliberately absent here — only the profile adds it. + PATH: ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(delimiter), + TERM: 'dumb', + SHELL: '/bin/bash', + ORCA_SHELL_READY_MARKER: '0', + ORCA_SHELL_STARTUP_IDENTITY: '0', + ORCA_CODEX_HOME: join(fixture.root, 'codex-home'), + ORCA_CODEX_LAUNCH_PREFLIGHT: preflightValue + } + }) +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH', () => { + it('sources the user profile before emitting the codex wrapper', () => { + const rcfile = getDaemonBashShellReadyRcfileContent() + + expect(rcfile.indexOf('source "$HOME/.bash_profile"')).toBeLessThan( + rcfile.indexOf('ORCA_CODEX_LAUNCH_PREFLIGHT') + ) + }) + + it('runs the bundled CLI the resolver picked, not the impostor the profile put first', () => { + const fixture = buildFixture() + const preflightCommand = resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + managedHomePath: join(fixture.root, 'codex-home'), + userDataPath: join(fixture.root, 'user-data'), + resourcesPath: fixture.resourcesPath + }) + expect(preflightCommand).toBe(getBundledLauncherPath(process.platform, fixture.resourcesPath)) + + launchCodexThroughRcfile(fixture, preflightCommand as string) + + expect(existsSync(fixture.intendedMarker)).toBe(true) + expect(existsSync(fixture.hijackMarker)).toBe(false) + // Guard against a vacuous pass: the wrapper must still have reached `command codex`. + expect(existsSync(fixture.codexMarker)).toBe(true) + }) + + // Why: pins the defect itself, so a regression back to an unqualified name fails here. + it('would run the impostor if the preflight carried an unqualified command name', () => { + const fixture = buildFixture() + + launchCodexThroughRcfile(fixture, 'orca') + + expect(existsSync(fixture.hijackMarker)).toBe(true) + expect(existsSync(fixture.intendedMarker)).toBe(false) + expect(existsSync(fixture.codexMarker)).toBe(true) + }) +}) diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index 52de4839701..e642c03dd77 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -1,6 +1,6 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' +import { delimiter, isAbsolute, join } from 'node:path' import { execFileSync, spawnSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' import { @@ -198,21 +198,112 @@ describe('PowerShell Codex shell launch preflight', () => { }) describe('Codex shell launch preflight command', () => { - it('uses the packaged or development CLI only for native managed homes', () => { + function makeCliRoot(): { root: string; userDataPath: string; resourcesPath: string } { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-cli-')) + roots.push(root) + const userDataPath = join(root, 'user-data') + const resourcesPath = join(root, 'resources') + mkdirSync(join(userDataPath, 'cli', 'bin'), { recursive: true }) + mkdirSync(join(resourcesPath, 'bin'), { recursive: true }) + return { root, userDataPath, resourcesPath } + } + + it.each([ + { platform: 'darwin' as const, bundled: 'orca' }, + { platform: 'linux' as const, bundled: 'orca-ide' }, + { platform: 'win32' as const, bundled: 'orca.exe' } + ])('carries the verified bundled $platform launcher as an absolute path', (config) => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(resourcesPath, 'bin', config.bundled) + writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') + expect( resolveCodexShellLaunchPreflightCommand({ hooksEnabled: true, isPackaged: true, - managedHomePath: '/managed/home' + managedHomePath: '/managed/home', + userDataPath, + resourcesPath, + platform: config.platform }) - ).toBe('orca') + ).toBe(launcherPath) + }) + + it('carries the verified dev launcher as an absolute path', () => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(userDataPath, 'cli', 'bin', 'orca-dev') + writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') + expect( resolveCodexShellLaunchPreflightCommand({ hooksEnabled: true, isPackaged: false, - managedHomePath: '/managed/home' + managedHomePath: '/managed/home', + userDataPath, + resourcesPath, + platform: 'darwin' }) - ).toBe('orca-dev') + ).toBe(launcherPath) + }) + + it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => { + const { userDataPath, resourcesPath } = makeCliRoot() + writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n') + writeExecutable(join(userDataPath, 'cli', 'bin', 'orca-dev'), '#!/bin/sh\nexit 0\n') + + for (const isPackaged of [true, false]) { + const command = resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged, + managedHomePath: '/managed/home', + userDataPath, + resourcesPath, + platform: 'darwin' + }) + expect(command).not.toBeNull() + expect(isAbsolute(command as string)).toBe(true) + } + }) + + it.each([ + { label: 'the launcher file is missing', create: null }, + { label: 'the launcher is not executable', create: 0o644 }, + { label: 'the launcher path is a directory', create: 'directory' as const } + ])('skips the preflight when $label', (config) => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(resourcesPath, 'bin', 'orca') + if (config.create === 'directory') { + mkdirSync(launcherPath) + } else if (config.create !== null) { + writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n') + chmodSync(launcherPath, config.create) + } + + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + managedHomePath: '/managed/home', + userDataPath, + resourcesPath, + platform: 'darwin' + }) + ).toBeNull() + }) + + it('skips the preflight when the packaged build exposes no resources root', () => { + const { userDataPath } = makeCliRoot() + + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + managedHomePath: '/managed/home', + userDataPath, + resourcesPath: null, + platform: 'darwin' + }) + ).toBeNull() }) it.each([ @@ -220,6 +311,103 @@ describe('Codex shell launch preflight command', () => { { hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home' }, { hooksEnabled: true, isWsl: false, managedHomePath: null } ])('does not enable an unsupported preflight for %o', (options) => { - expect(resolveCodexShellLaunchPreflightCommand({ ...options, isPackaged: true })).toBeNull() + const { userDataPath, resourcesPath } = makeCliRoot() + writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n') + + expect( + resolveCodexShellLaunchPreflightCommand({ + ...options, + isPackaged: true, + userDataPath, + resourcesPath, + platform: 'darwin' + }) + ).toBeNull() }) }) + +// Why: the resolved value is now an absolute path, and app bundles (macOS) and +// Program Files (Windows) both put spaces in it. +describe.skipIf(process.platform === 'win32')('Codex preflight paths containing spaces', () => { + function writeSpacedPreflight(root: string): { preflightPath: string; markerPath: string } { + const dir = join(root, 'Orca Dev.app', 'Contents', 'Resources', 'bin') + mkdirSync(dir, { recursive: true }) + const markerPath = join(root, 'preflight-ran') + const preflightPath = join(dir, 'orca') + writeExecutable( + preflightPath, + `#!/bin/sh\n[ "$1 $2 $3" = "agent hooks prepare-codex" ] || exit 2\nprintf ran > ${JSON.stringify(markerPath)}\n` + ) + return { preflightPath, markerPath } + } + + it('invokes a POSIX preflight whose absolute path contains spaces', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-spaced-posix-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + writeExecutable(join(bin, 'codex'), '#!/bin/sh\nexit 0\n') + const { preflightPath, markerPath } = writeSpacedPreflight(root) + + execFileSync( + '/bin/bash', + ['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`], + { + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, + ORCA_CODEX_LAUNCH_PREFLIGHT: preflightPath + } + } + ) + + expect(existsSync(markerPath)).toBe(true) + }) + + it.skipIf(!fishAvailable)('invokes a fish preflight whose absolute path contains spaces', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-spaced-fish-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + writeExecutable(join(bin, 'codex'), '#!/bin/sh\nexit 0\n') + const { preflightPath, markerPath } = writeSpacedPreflight(root) + + execFileSync('fish', ['--no-config', '-c', `${getFishCodexShellLaunchPreflight()}\ncodex`], { + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, + ORCA_CODEX_LAUNCH_PREFLIGHT: preflightPath + } + }) + + expect(existsSync(markerPath)).toBe(true) + }) + + it.skipIf(!pwshAvailable)( + 'invokes a PowerShell preflight whose absolute path contains spaces', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-spaced-pwsh-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n') + const { preflightPath, markerPath } = writeSpacedPreflight(root) + + const result = spawnSync( + 'pwsh', + ['-NoLogo', '-NoProfile', '-Command', `${getPowerShellCodexShellLaunchPreflight()}\ncodex`], + { + encoding: 'utf-8', + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, + ORCA_CODEX_LAUNCH_PREFLIGHT: preflightPath + } + } + ) + + expect(result.status, result.stderr).toBe(0) + expect(existsSync(markerPath)).toBe(true) + } + ) +}) diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 049ddda72af..9242eba0559 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -1,13 +1,61 @@ -export function resolveCodexShellLaunchPreflightCommand(options: { +import { accessSync, constants, statSync } from 'node:fs' +import { join } from 'node:path' +import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path' + +const DEV_LAUNCHER_DIR = ['cli', 'bin'] +const DEV_COMMAND_NAME = 'orca-dev' + +export type CodexShellLaunchPreflightCommandOptions = { hooksEnabled: boolean isPackaged: boolean isWsl?: boolean managedHomePath: string | null -}): string | null { + /** Where the dev launcher is written; `join(userDataPath, 'cli', 'bin')` is also what managed dev PTYs prepend to PATH. */ + userDataPath: string + /** Packaged app resources root; the bundled launcher lives under it. */ + resourcesPath?: string | null + /** Test seam. */ + platform?: NodeJS.Platform +} + +/** Absolute path of the Orca CLI the preflight must execute, or null to skip it. + * + * Why absolute: the value rides in ORCA_CODEX_LAUNCH_PREFLIGHT and is invoked + * from the codex() wrapper, which shell-ready emits *after* the user's profile + * scripts run. Those scripts routinely rewrite PATH, so an unqualified name + * would be resolved against a PATH Orca neither controls nor can predict — + * handing Orca's managed Codex environment to an unidentified program. When no + * path verifies, skipping the preflight is the predictable degradation. */ +export function resolveCodexShellLaunchPreflightCommand( + options: CodexShellLaunchPreflightCommandOptions +): string | null { if (!options.hooksEnabled || options.isWsl || !options.managedHomePath) { return null } - return options.isPackaged ? 'orca' : 'orca-dev' + const platform = options.platform ?? process.platform + const candidate = options.isPackaged + ? options.resourcesPath + ? getBundledLauncherPath(platform, options.resourcesPath) + : null + : join( + options.userDataPath, + ...DEV_LAUNCHER_DIR, + platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME + ) + return candidate && isExecutableFileOnDisk(candidate, platform) ? candidate : null +} + +function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(path).isFile()) { + return false + } + // Why: Windows has no exec bit, so a readable launcher file is the strongest signal available. + accessSync(path, platform === 'win32' ? constants.R_OK : constants.X_OK) + return true + } catch { + return false + } } export function getPosixCodexShellLaunchPreflight(): string { diff --git a/src/main/pty/legacy-terminal-posix-tombstone.ts b/src/main/pty/legacy-terminal-posix-tombstone.ts index abe82ac485e..f2541b63e61 100644 --- a/src/main/pty/legacy-terminal-posix-tombstone.ts +++ b/src/main/pty/legacy-terminal-posix-tombstone.ts @@ -1,5 +1,5 @@ -import { accessSync, constants, statSync } from 'node:fs' -import { join } from 'node:path' +import { accessSync, constants, readFileSync, statSync } from 'node:fs' +import { basename, join } from 'node:path' const SHELL_DOLLAR = '$' @@ -36,13 +36,14 @@ function isExecutable(candidate: string): boolean { // directly rather than falling through to an ambient lookup. const WINDOWS_POSIX_INTERPRETER = '/bin/bash' +/** Returns null when no absolute interpreter can be verified; see the caller for what that means. */ export function resolvePosixTombstoneInterpreter( pathValue: string | undefined = process.env.PATH, // Why: injectable so the PATH-search branch below is reachable in tests on hosts that do have // a well-known bash. candidates: readonly string[] = POSIX_INTERPRETER_CANDIDATES, platform: NodeJS.Platform = process.platform -): string { +): string | null { if (platform === 'win32') { return WINDOWS_POSIX_INTERPRETER } @@ -64,25 +65,53 @@ export function resolvePosixTombstoneInterpreter( return candidate } } - // Why: last resort. Leaves the ambient lookup in place, but only when no absolute bash exists - // anywhere, in which case the wrapper would not run at all otherwise. - return '/usr/bin/env bash' + // Why: no absolute interpreter anywhere. Callers must delete the legacy wrapper rather than + // write one with an ambient shebang, which would resolve bash from the cwd. + return null +} + +/** + * Returns the absolute interpreter the wrapper being replaced already used, or null. + * + * Why: when this process cannot see an absolute bash, deleting the wrapper strands a shell that + * has the path hashed -- it reports 127 rather than falling through to the next PATH entry. The + * file about to be overwritten ran on this host, so its own shebang names an interpreter known to + * work here, and reusing it keeps the compatibility promise without an ambient lookup. + */ +export function readVerifiedShebangInterpreter(filePath: string): string | null { + let firstLine: string + try { + firstLine = readFileSync(filePath, 'utf8').split('\n', 1)[0] ?? '' + } catch { + return null + } + const match = /^#!\s*(\S+)/.exec(firstLine) + const interpreter = match?.[1] + if (!interpreter?.startsWith('/')) { + return null + } + // Why exactly bash: the body below uses BASH_SOURCE, [[, and local, so any other shell fails at + // runtime -- a #!/bin/zsh wrapper was accepted and then exited 1 on `BASH_SOURCE[0]: parameter + // not set`. This also rejects `#!/usr/bin/env bash`, which is absolute but defers the real + // lookup to PATH, the current-directory exposure this whole path exists to avoid. + if (basename(interpreter) !== 'bash') { + return null + } + return isExecutable(interpreter) ? interpreter : null } const POSIX_TOMBSTONE = String.raw`#!__ORCA_INTERPRETER__ set -u command_name="__ORCA_COMMAND__" -# Why: parameter expansion, not the external dirname — if that were unresolvable the substitution -# would be empty and cd into it succeeds, silently making wrapper_dir the cwd so the wrapper -# fails to exclude itself from PATH. +# Why this shape, rather than dirname: an unresolvable external would leave the substitution empty +# and cd into it succeeds, silently making wrapper_dir the cwd. With no slash the %/* strip yields the +# file name rather than a directory, so that case takes $PWD. CDPATH is cleared because cd searches +# it for a relative operand and echoes where it landed, which the substitution would capture. +# Each of these made self-exclusion miss the wrapper's own directory, so the lookup resolved back +# to this script. wrapper_src="${SHELL_DOLLAR}{BASH_SOURCE[0]}" case "$wrapper_src" in - # Why: with no slash the %/* strip yields the file name, not a directory, so self-exclusion - # would miss the wrapper's own dir and the lookup would resolve back to this script. - # Why: with CDPATH set, cd searches it for a relative operand and echoes the directory it lands - # in, which the command substitution then captures — wrapper_dir ends up wrong and doubled. - # Clear it for this one command. */*) wrapper_dir="$(CDPATH= cd -P -- "${SHELL_DOLLAR}{wrapper_src%/*}" 2>/dev/null && pwd)" ;; *) wrapper_dir="$PWD" ;; esac @@ -99,7 +128,7 @@ filter_path() { local filtered_path="" local separator="" path_entry_kept=0 - local entry normalized candidate has_more + local entry normalized has_more while true; do if [[ "$remaining" == *:* ]]; then entry="${SHELL_DOLLAR}{remaining%%:*}" @@ -113,14 +142,22 @@ filter_path() { while [[ "$normalized" != "/" && "$normalized" == */ ]]; do normalized="${SHELL_DOLLAR}{normalized%/}" done - candidate="${SHELL_DOLLAR}{entry:-.}" if [[ "$entry" != /* ]]; then # Why: an empty or relative PATH element resolves against the current directory, so keeping # it would let a repository-local git/gh win the lookup below. : elif [[ -n "$legacy_target" && "$normalized" == "$legacy_target" ]]; then : - elif [[ "$candidate" -ef "$wrapper_dir" ]]; then + elif [[ "$legacy_target" == /* && "$entry" -ef "$legacy_target" ]]; then + # Why -ef as well as the lexical test above: it compares filesystem identity, so it catches + # the same directory reached through a symlink or a /legacy/../legacy spelling. It is false + # when either path is gone, which is when the lexical test still holds, so neither alone is + # enough. + # Why only for an absolute target: bash resolves a relative -ef operand against the current + # directory, so a relative ORCA_ATTRIBUTION_SHIM_DIR let the cwd decide which PATH entry + # counted as the legacy directory and got a legitimate one skipped. + : + elif [[ "$entry" -ef "$wrapper_dir" ]]; then : else filtered_path+="$separator$entry" @@ -152,7 +189,7 @@ PATH="$cleaned_path" exec "$real_command" "$@" export function renderLegacyTerminalPosixTombstone( command: 'git' | 'gh', - interpreter = resolvePosixTombstoneInterpreter() + interpreter: string = resolvePosixTombstoneInterpreter() ?? WINDOWS_POSIX_INTERPRETER ): string { return POSIX_TOMBSTONE.replaceAll('__ORCA_INTERPRETER__', interpreter).replaceAll( '__ORCA_COMMAND__', diff --git a/src/main/pty/legacy-terminal-shim-dir.test.ts b/src/main/pty/legacy-terminal-shim-dir.test.ts index 47a3b96825b..d2ac720dbb7 100644 --- a/src/main/pty/legacy-terminal-shim-dir.test.ts +++ b/src/main/pty/legacy-terminal-shim-dir.test.ts @@ -13,9 +13,14 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { resolvePosixTombstoneInterpreter } from './legacy-terminal-posix-tombstone' +import type * as PosixTombstoneModule from './legacy-terminal-posix-tombstone' +import { + readVerifiedShebangInterpreter, + resolvePosixTombstoneInterpreter +} from './legacy-terminal-posix-tombstone' import { __resetLegacyTerminalShimNeutralizationForTests, + isLegacyTerminalShimPathEntry, neutralizeLegacyTerminalShimDir, stripLegacyTerminalShimEnv } from './legacy-terminal-shim-dir' @@ -83,153 +88,6 @@ describe('legacy terminal shim neutralization', () => { expect(version.trim()).not.toBe('7') }) - it('rejects stale Windows real-command paths inside the wrapper directory', () => { - const userData = makeUserDataDir() - const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') - mkdirSync(win32Dir, { recursive: true }) - - neutralizeLegacyTerminalShimDir(userData) - - const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8') - expect(cmd).toContain( - 'if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real="' - ) - // Why: a captured path that no longer exists must be cleared, or the where.exe fallback below - // is skipped and the wrapper execs a missing binary. - expect(cmd).toContain('if defined orca_real if not exist "%orca_real%" set "orca_real="') - expectOrdered(cmd, 'if not exist "%orca_real%"', ':orca_try_candidate') - const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8') - expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase') - expect(powershell).toContain('$realCommand = $null') - expectOrdered( - powershell, - '[StringComparison]::OrdinalIgnoreCase', - 'Test-Path -LiteralPath $realCommand' - ) - }) - - it('resolves Windows fallbacks against PATH only, never the current directory', () => { - // Why (STA-4169): bare `where.exe git.exe` searches cwd before PATH, so a repository-local - // git.exe/gh.exe could be executed with the user's arguments. - const userData = makeUserDataDir() - const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') - mkdirSync(win32Dir, { recursive: true }) - - neutralizeLegacyTerminalShimDir(userData) - - for (const command of ['git', 'gh'] as const) { - const cmd = readFileSync(join(win32Dir, `${command}.cmd`), 'utf8') - // No where.exe at all: it searches cwd first, so the wrapper walks the cleaned PATH. - expect(cmd).not.toContain('where.exe') - expect(cmd).toContain('for %%P in ("%orca_clean_path:;=" "%") do call :orca_try_candidate') - expect(cmd).toContain(`if exist "%orca_candidate_dir%\\${command}.exe"`) - // Why: %~f preserves a trailing separator; without normalizing, a wrapper-dir entry - // spelled with one escapes self-exclusion and the wrapper tail-loops on itself. - // Why: `if "%var:~-1%"=="\\"` breaks cmd's parser, so the trailing separator is stripped - // with a sentinel instead. Verified on Windows. - // Why: compared against a variable holding the separator — a literal backslash before the - // closing quote breaks cmd's parser, and a sentinel would corrupt paths containing it. - // The value matters: any other character silently un-pins the trailing-separator fix. - expect(cmd).toContain('set "orca_sep=\\"') - // Why: nothing else asserts the *reject* path, so deleting it would reopen the cwd hijack - // while every accept-path assertion stayed green. - expect(cmd).toMatch(/goto orca_candidate_rooted\r?\nexit \/b/) - expect(cmd).toContain('if "%orca_candidate_dir:~-1%"=="%orca_sep%"') - expect(cmd).not.toContain(':\\#=#%') - // A candidate inside the wrapper directory must still be rejected, compared against the - // cached wrapper dir because %~dp0 is rebound inside a CALL. - expect(cmd).toContain('if /I "%orca_candidate_dir%\\"=="%orca_wrapper_dir%" exit /b') - // Relative entries resolve against the cwd, so they must be rejected like empty ones. - // Why: the rooted-path test must not shell out — an external tool would itself be - // resolved from the cwd, reintroducing the hijack. - expect(cmd).not.toContain('findstr') - expect(cmd).toContain('if "%orca_candidate:~1,2%"==":\\" goto orca_candidate_rooted') - expect(cmd).toContain('if "%orca_candidate:~0,2%"=="\\\\" goto orca_candidate_rooted') - // Why: these two guards are the only thing stopping an empty element reaching the cwd on - // Windows; deleting either left every other assertion green. - expect(cmd).toContain('if "%~1"=="" exit /b') - - const powershell = readFileSync(join(win32Dir, `${command}-wrapper.ps1`), 'utf8') - expect(powershell).not.toContain('Get-Command') - expect(powershell).toContain("($env:PATH -split ';')") - // Why: the rooted check must reject drive-relative 'C:foo', which still resolves against - // the cwd — so the IsPathRooted call must be gone, replaced by an explicit prefix match. - expect(powershell).not.toContain('[IO.Path]::IsPathRooted(') - // Why the full pattern, not a prefix: `^([A-Za-z]:|\\\\)` also starts with this and would - // accept drive-relative `C:foo`, which still resolves against the cwd on that drive. - expect(powershell).toContain("-notmatch '^([A-Za-z]:[\\\\/]|\\\\\\\\)'") - // Why: trailing-separator normalization is what makes wrapper self-exclusion work; every - // one of these survived mutation with the suite green. - expect(powershell).toContain("ForEach-Object { $_.TrimEnd('\\') }") - expect(powershell).toContain("$pathEntry.TrimEnd('\\')") - expect(powershell).toContain("$dir.TrimEnd('\\')") - expect(powershell).toContain("$capturedDir.TrimEnd('\\')") - // Why: the fallback loop must skip wrapper dirs, or the wrapper can resolve to itself. - expect(powershell).toContain( - "$dir.TrimEnd('\\'), [StringComparison]::OrdinalIgnoreCase) }) { continue }" - ) - // Why: `C:/Program Files/Git/cmd` style entries are legitimate and must stay accepted. - expect(cmd).toContain('if "%orca_candidate:~1,2%"==":/" goto orca_candidate_rooted') - // Why: the legacy-dir compare needs the same trailing-separator strip as its twins. - expect(cmd).toContain('if "%orca_legacy_norm:~-1%"=="%orca_sep%"') - expect(powershell).toContain('if (-not $dir) { continue }') - expect(powershell).toContain('Test-Path -LiteralPath $candidate -PathType Leaf') - } - }) - - it('emits Windows wrappers with CRLF line endings', () => { - // Why: cmd resolves `call :label` by byte offset and that lookup is unreliable in LF-only - // files — the same script worked at 2.4 KB and failed with "cannot find the batch label" - // once it grew past ~3.7 KB. Verified on Windows. - const userData = makeUserDataDir() - const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') - mkdirSync(win32Dir, { recursive: true }) - - neutralizeLegacyTerminalShimDir(userData) - - for (const file of ['git.cmd', 'gh.cmd', 'git-wrapper.ps1', 'gh-wrapper.ps1']) { - const body = readFileSync(join(win32Dir, file), 'utf8') - expect(body, file).toContain('\r\n') - expect(body.replaceAll('\r\n', ''), file).not.toContain('\n') - } - }) - - it('guards both cmd PATH walks so an empty variable cannot break parsing', () => { - // Why: `%VAR:;=" "%` on an empty variable leaves an unbalanced quote that desynchronizes - // cmd parsing for the rest of the file, turning the not-found branch into - // `1>&2 was unexpected at this time.` Reproduced on Windows before this guard. - const userData = makeUserDataDir() - const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') - mkdirSync(win32Dir, { recursive: true }) - - neutralizeLegacyTerminalShimDir(userData) - - for (const command of ['git', 'gh'] as const) { - const cmd = readFileSync(join(win32Dir, `${command}.cmd`), 'utf8') - expect(cmd).toContain('if not defined PATH goto :orca_path_walked') - expect(cmd).toContain('if not defined orca_clean_path goto :orca_candidates_walked') - for (const [guard, loop] of [ - ['if not defined PATH goto :orca_path_walked', 'for %%P in ("%PATH:;='], - [ - 'if not defined orca_clean_path goto :orca_candidates_walked', - 'for %%P in ("%orca_clean_path:;=' - ] - ] as const) { - expectOrdered(cmd, guard, loop) - } - } - }) - - it('never bakes an ambient interpreter lookup on Windows', () => { - // Why: the POSIX tombstone is written on Windows too (Git Bash and WSL panes run it), but no - // absolute candidate exists to a Windows process and PATH there is ';'-separated, so the - // search cannot succeed — without this the fallback reopened the interpreter hijack. - expect(resolvePosixTombstoneInterpreter(undefined, [], 'win32')).toBe('/bin/bash') - expect(resolvePosixTombstoneInterpreter('C:\\Git\\bin;C:\\Windows', [], 'win32')).toBe( - '/bin/bash' - ) - }) - itOnPosix('rejects interpreter candidates that are unusable in a shebang', () => { const userData = makeUserDataDir() const spaced = join(userData, 'a b') @@ -239,9 +97,9 @@ describe('legacy terminal shim neutralization', () => { writeFileSync(join(spaced, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }) // A shebang cannot quote, so a path containing whitespace is unusable. - expect(resolvePosixTombstoneInterpreter(spaced, [], 'linux')).toBe('/usr/bin/env bash') + expect(resolvePosixTombstoneInterpreter(spaced, [], 'linux')).toBeNull() // X_OK is true for a directory; it still cannot be exec'd. - expect(resolvePosixTombstoneInterpreter(dirNamedBash, [], 'linux')).toBe('/usr/bin/env bash') + expect(resolvePosixTombstoneInterpreter(dirNamedBash, [], 'linux')).toBeNull() }) itOnPosix('resolves the interpreter from absolute PATH entries only', () => { @@ -267,7 +125,7 @@ describe('legacy terminal shim neutralization', () => { mkdirSync(cwdRelDir, { recursive: true }) try { writeFileSync(join(cwdRelDir, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }) - expect(resolvePosixTombstoneInterpreter(`.:${cwdRelName}`, [])).toBe('/usr/bin/env bash') + expect(resolvePosixTombstoneInterpreter(`.:${cwdRelName}`, [])).toBeNull() } finally { rmSync(cwdRelDir, { recursive: true, force: true }) } @@ -372,6 +230,196 @@ describe('legacy terminal shim neutralization', () => { expect(run.stdout).not.toContain('HOSTILE') }) + itOnPosix('rejects the legacy shim directory reached by a different spelling', () => { + // Why: the legacy-dir compare was lexical while the self-compare used -ef, so a PATH entry + // spelled `/../` or reached through a symlink named the same directory but + // survived the filter, and the still-live attribution wrapper won the lookup. + const userData = makeUserDataDir() + const posixDir = join(userData, 'orca-terminal-attribution', 'posix') + const legacyDir = join(userData, 'legacy-shim') + const realBin = join(userData, 'real-bin') + for (const dir of [posixDir, legacyDir, realBin]) { + mkdirSync(dir, { recursive: true }) + } + writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper') + + neutralizeLegacyTerminalShimDir(userData) + + writeFileSync(join(legacyDir, 'git'), "#!/bin/bash\nprintf 'HOSTILE\\n'\n", { mode: 0o755 }) + writeFileSync(join(realBin, 'git'), "#!/bin/bash\nprintf 'REAL\\n'\n", { mode: 0o755 }) + const legacyLink = join(userData, 'legacy-link') + symlinkSync(legacyDir, legacyLink) + + for (const spelling of [join(legacyDir, '..', 'legacy-shim'), legacyLink]) { + const run = spawnSync(join(posixDir, 'git'), ['--version'], { + env: { + ...process.env, + ORCA_ATTRIBUTION_SHIM_DIR: legacyDir, + PATH: `${spelling}:${realBin}:/usr/bin:/bin` + }, + encoding: 'utf8', + timeout: 20_000 + }) + expect(run.stdout, `spelling ${spelling}`).toContain('REAL') + expect(run.stdout, `spelling ${spelling}`).not.toContain('HOSTILE') + } + }) + + itOnPosix('reuses the replaced wrapper shebang when no absolute bash is verifiable', () => { + // Why: deleting the wrapper strands a shell that already hashed the path -- it reports 127 + // rather than falling through to PATH. The file being replaced ran on this host, so its own + // shebang names an interpreter known to work here. + const userData = makeUserDataDir() + const wrapper = join(userData, 'git') + writeFileSync(wrapper, '#!/bin/bash\nexit 0\n', { mode: 0o755 }) + expect(readVerifiedShebangInterpreter(wrapper)).toBe('/bin/bash') + + // Why: `env` is absolute but defers the lookup to PATH, which is the cwd exposure this exists + // to close. Accepting it would silently reinstate the hijack the null branch prevents. + writeFileSync(wrapper, '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 }) + expect(readVerifiedShebangInterpreter(wrapper)).toBeNull() + + // Why /bin/sh: it exists on every POSIX host, so this kills the mutant deterministically. + // The rendered body needs BASH_SOURCE, [[ and local, so any other shell exits 1 at runtime. + writeFileSync(wrapper, '#!/bin/sh\nexit 0\n', { mode: 0o755 }) + expect(readVerifiedShebangInterpreter(wrapper)).toBeNull() + + writeFileSync(wrapper, '#!bash\nexit 0\n', { mode: 0o755 }) + expect(readVerifiedShebangInterpreter(wrapper)).toBeNull() + + writeFileSync(wrapper, '#!/nonexistent/bash\nexit 0\n', { mode: 0o755 }) + expect(readVerifiedShebangInterpreter(wrapper)).toBeNull() + + writeFileSync(wrapper, 'no shebang at all\n', { mode: 0o755 }) + expect(readVerifiedShebangInterpreter(wrapper)).toBeNull() + + expect(readVerifiedShebangInterpreter(join(userData, 'absent'))).toBeNull() + }) + + itOnPosix('keeps the POSIX wrapper on a host where no absolute bash resolves', async () => { + // Why through neutralizeLegacyTerminalShimDir, not the resolver alone: CI hosts always have + // /bin/bash, so the primary branch is always taken and deleting the fallback wiring left the + // suite green. Forcing the resolver to null is the only way to reach it. + vi.resetModules() + vi.doMock('./legacy-terminal-posix-tombstone', async () => { + const actual = await vi.importActual( + './legacy-terminal-posix-tombstone' + ) + return { ...actual, resolvePosixTombstoneInterpreter: () => null } + }) + try { + const shimDir = await import('./legacy-terminal-shim-dir') + shimDir.__resetLegacyTerminalShimNeutralizationForTests() + const userData = makeUserDataDir() + const posixDir = join(userData, 'orca-terminal-attribution', 'posix') + mkdirSync(posixDir, { recursive: true }) + writeFileSync(join(posixDir, 'git'), '#!/bin/bash\nlegacy attribution wrapper\n', { + mode: 0o755 + }) + // Why gh differs: its wrapper carries no reusable shebang, so it must still be deleted + // rather than left executing the retired attribution logic. + writeFileSync(join(posixDir, 'gh'), '#!/usr/bin/env bash\nlegacy\n', { mode: 0o755 }) + + shimDir.neutralizeLegacyTerminalShimDir(userData) + + const git = readFileSync(join(posixDir, 'git'), 'utf8') + expect(git.split('\n')[0]).toBe('#!/bin/bash') + expect(git).toContain('Orca compatibility wrapper could not locate') + expect(existsSync(join(posixDir, 'gh'))).toBe(false) + } finally { + vi.doUnmock('./legacy-terminal-posix-tombstone') + vi.resetModules() + } + }) + + it('does not collapse .. when classifying a shim PATH entry', () => { + // Why deliberately not collapsed: collapsing `..` textually is not resolving it. If + // `/posix` is a symlink, `/posix/../posix` lands somewhere else, and classifying + // it as the shim directory deletes a legitimate PATH entry and leaves git unresolvable. + // Resolving for real is not available here either: this env is also built for remote and WSL + // panes whose paths do not exist on the local filesystem. + expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/posix/../posix')).toBe( + false + ) + expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/posix')).toBe(true) + expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/win32//')).toBe(true) + expect(isLegacyTerminalShimPathEntry('/usr/local/bin')).toBe(false) + }) + + it('strips a captured shim directory spelled with repeated separators', () => { + // Why: pathEntrySpellings can only enumerate one added separator, so an entry repeating it + // survived the literal removal and kept the captured directory on PATH. + const posixEnv = { + PATH: '/custom/elsewhere///:/usr/bin', + ORCA_ATTRIBUTION_SHIM_DIR: '/custom/elsewhere' + } + stripLegacyTerminalShimEnv(posixEnv, 'linux') + expect(posixEnv.PATH).toBe('/usr/bin') + + const windowsEnv = { + Path: 'C:\\Custom\\Else\\\\;C:\\Windows', + ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\Custom\\Else' + } + stripLegacyTerminalShimEnv(windowsEnv, 'win32') + expect(windowsEnv.Path).toBe('C:\\Windows') + }) + + itOnPosix( + 'ignores a relative legacy shim directory instead of resolving it against the cwd', + () => { + // Why: bash resolves a relative -ef operand against the wrapper's current directory, so a + // relative ORCA_ATTRIBUTION_SHIM_DIR let the cwd decide which PATH entry counted as the legacy + // directory. Reproduced as SAFE vs LATER purely by changing the cwd. + const userData = makeUserDataDir() + const posixDir = join(userData, 'orca-terminal-attribution', 'posix') + const safeBin = join(userData, 'safe-bin') + const laterBin = join(userData, 'later-bin') + for (const dir of [posixDir, safeBin, laterBin]) { + mkdirSync(dir, { recursive: true }) + } + writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper') + + neutralizeLegacyTerminalShimDir(userData) + + writeFileSync(join(safeBin, 'git'), "#!/bin/bash\nprintf 'SAFE\\n'\n", { mode: 0o755 }) + writeFileSync(join(laterBin, 'git'), "#!/bin/bash\nprintf 'LATER\\n'\n", { mode: 0o755 }) + + const outputs = [userData, join(userData, 'orca-terminal-attribution')].map((cwd) => { + const run = spawnSync(join(posixDir, 'git'), ['--version'], { + cwd, + env: { + ...process.env, + ORCA_ATTRIBUTION_SHIM_DIR: 'safe-bin', + PATH: `${safeBin}:${laterBin}:/usr/bin:/bin` + }, + encoding: 'utf8', + timeout: 20_000 + }) + return run.stdout.trim() + }) + + // Why identical: which binary runs must not depend on where the wrapper was invoked from. + expect(outputs[0]).toBe(outputs[1]) + expect(outputs[0]).toBe('SAFE') + } + ) + + it('keeps a POSIX directory whose name ends in a backslash', () => { + // Why: a backslash is a legal filename character on POSIX. Treating it as a separator made + // `/tmp/captured\` and `/tmp/captured` compare equal and deleted a real directory from PATH. + const env = { + PATH: '/tmp/captured\\', + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/captured' + } + stripLegacyTerminalShimEnv(env, 'linux') + expect(env.PATH).toBe('/tmp/captured\\') + + // Why the Windows half: there a backslash really is a separator, so it must still be stripped. + const windowsEnv = { Path: 'C:\\captured\\', ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\captured' } + stripLegacyTerminalShimEnv(windowsEnv, 'win32') + expect(windowsEnv.Path).toBeUndefined() + }) + itOnPosix('does not fall back to the cwd when every PATH entry is filtered out', () => { // Why: with the shim dir as the only entry the cleaned PATH is empty; without the // path_entry_kept guard the lookup runs against that empty PATH and finds a cwd-local git. @@ -534,13 +582,13 @@ describe('legacy terminal shim neutralization', () => { const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8') const cmdCapture = 'set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%"' expectOrdered(cmd, cmdCapture, 'set "ORCA_ATTRIBUTION_SHIM_DIR="') - expect(cmd).toContain('for %%P in ("%PATH:;=" "%") do call :orca_append_path "%%~P"') + expect(cmd).toContain('for %%P in ("%PATH:;=" "%") do (') expect(cmd).toContain('if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b') expect(cmd).toContain('if defined orca_legacy_wrapper_dir call :orca_reject_legacy_dir') // Why: `call :label && ...` is not valid cmd; the flag variable is what makes it work. expect(cmd).toContain('if defined orca_skip_entry exit /b') expect(cmd).not.toContain('call :orca_reject_legacy_dir &&') - expect(cmd).toContain('if "%orca_path_entry_dir:~-1%"=="%orca_sep%"') + expect(cmd).toContain('if "%orca_path_entry_dir:~-1%."=="%orca_sep%."') const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8') expectOrdered( @@ -707,6 +755,25 @@ describe('legacy terminal shim neutralization', () => { expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin', HOME: '/home/u' }) }) + it('strips the captured shim directory when PATH spells it with a trailing separator', () => { + // Why: the captured value and the PATH entry can differ by a trailing separator; comparing + // them literally left the shim directory on PATH and the wrapper reachable. + const posix: Record = { + PATH: '/custom/elsewhere/:/usr/bin', + ORCA_ATTRIBUTION_SHIM_DIR: '/custom/elsewhere' + } + stripLegacyTerminalShimEnv(posix, 'linux') + expect(posix.PATH).toBe('/usr/bin') + + // And the reverse spelling, plus Windows slash style. + const win: Record = { + Path: 'C:\\Custom\\Else;C:\\Windows', + ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\Custom\\Else\\' + } + stripLegacyTerminalShimEnv(win, 'win32') + expect(win.Path).toBe('C:\\Windows') + }) + it('uses the captured POSIX shim directory literally when it contains a colon', () => { const shimDir = '/tmp/orca:user/orca-terminal-attribution/posix' const env: Record = { diff --git a/src/main/pty/legacy-terminal-shim-dir.ts b/src/main/pty/legacy-terminal-shim-dir.ts index 0d380bcfd65..a1f5022e97c 100644 --- a/src/main/pty/legacy-terminal-shim-dir.ts +++ b/src/main/pty/legacy-terminal-shim-dir.ts @@ -1,6 +1,10 @@ import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { renderLegacyTerminalPosixTombstone } from './legacy-terminal-posix-tombstone' +import { + readVerifiedShebangInterpreter, + renderLegacyTerminalPosixTombstone, + resolvePosixTombstoneInterpreter +} from './legacy-terminal-posix-tombstone' import { renderLegacyTerminalWindowsCmdTombstone, renderLegacyTerminalWindowsPowerShellTombstone @@ -95,8 +99,19 @@ function writeNeutralWrappers(rootDir: string): void { const win32Dir = join(rootDir, 'win32') mkdirSync(posixDir, { recursive: true }) mkdirSync(win32Dir, { recursive: true }) + // Why: a tombstone with no verifiable absolute interpreter would need an ambient shebang and + // could take bash from the current directory. Falling back to the shebang of the wrapper being + // replaced keeps a shell that has the path hashed working -- deleting the file strands it on + // 127 rather than falling through to PATH. Deleting is the last resort, not the first. + const interpreter = resolvePosixTombstoneInterpreter() for (const command of ['git', 'gh'] as const) { - writeFileAtomically(join(posixDir, command), renderLegacyTerminalPosixTombstone(command), 0o755) + const posixPath = join(posixDir, command) + const resolved = interpreter ?? readVerifiedShebangInterpreter(posixPath) + if (resolved === null) { + rmSync(posixPath, { force: true }) + } else { + writeFileAtomically(posixPath, renderLegacyTerminalPosixTombstone(command, resolved), 0o755) + } writeFileAtomically( join(win32Dir, `${command}.cmd`), renderLegacyTerminalWindowsCmdTombstone(command), @@ -126,14 +141,62 @@ function writeFileAtomically(filePath: string, contents: string, mode: number): } } +// Why: the captured directory and the PATH entry naming it can differ by a trailing separator (or +// its slash style on Windows). A literal compare missed that and left the shim dir on PATH. The +// literal compare itself has to stay: a shim path may contain the PATH delimiter, which splitting +// would fragment. +function pathEntrySpellings(dir: string, windows: boolean): string[] { + const base = stripTrailingSeparators(dir, windows) + if (!base) { + return [dir] + } + const spellings = new Set([dir, base, `${base}/`]) + if (windows) { + spellings.add(`${base}\\`) + } + return [...spellings] +} + +// Why purely lexical, and why `..` is deliberately not collapsed: collapsing it textually is not +// the same as resolving it. If `/posix` is a symlink, `/posix/../posix` resolves +// somewhere else entirely, and treating it as the shim directory deletes a legitimate PATH entry +// and leaves git unresolvable. Resolving for real is not an option either: this env is also built +// for remote and WSL panes, where these paths do not name anything on the local filesystem. +// A `..` spelling that does slip through costs nothing at runtime -- the directory now holds the +// pass-through tombstone, and the tombstone excludes its own directory by -ef, so the lookup still +// reaches the real git. export function isLegacyTerminalShimPathEntry(entry: string): boolean { - const normalized = entry.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase() + // Why `windows` unconditionally here: this classifier only ever matches Orca's own + // `orca-terminal-attribution/{posix,win32}` layout, and a Windows PATH can reach it through the + // remote env, so both slash styles must be understood regardless of the local platform. + const normalized = stripTrailingSeparators(entry.replaceAll('\\', '/'), true).toLowerCase() return ( normalized.endsWith(`/${LEGACY_SHIM_ROOT_DIR}/posix`) || normalized.endsWith(`/${LEGACY_SHIM_ROOT_DIR}/win32`) ) } +// Why: `pathEntrySpellings` can only enumerate one added separator, so an entry repeating it +// survived the literal removal. Comparing separator-stripped forms covers any number of them. +// Why platform-specific: a backslash is a legal filename character on POSIX, so treating it as a +// separator there made `/tmp/captured\` and `/tmp/captured` compare equal and deleted a real +// directory from PATH. +function stripTrailingSeparators(value: string, windows: boolean): string { + return value.replace(windows ? /[\\/]+$/ : /\/+$/, '') +} + +function namesCapturedShimDir(entry: string, shimDirs: string[], windows: boolean): boolean { + const candidate = stripTrailingSeparators(entry, windows) + return shimDirs.some((shimDir) => { + const target = stripTrailingSeparators(shimDir, windows) + return target + ? windows + ? candidate.toLowerCase() === target.toLowerCase() + : candidate === target + : false + }) +} + export function stripLegacyTerminalShimEnv( env: Record, platform: NodeJS.Platform = process.platform @@ -164,12 +227,21 @@ export function stripLegacyTerminalShimEnv( continue } const withoutExplicitDirs = explicitShimDirs.reduce( - (pathValue, shimDir) => removeLiteralPathEntry(pathValue, shimDir, delimiter, windows), + (pathValue, shimDir) => + pathEntrySpellings(shimDir, windows).reduce( + (value, spelling) => removeLiteralPathEntry(value, spelling, delimiter, windows), + pathValue + ), current ) const cleaned = withoutExplicitDirs .split(delimiter) - .filter((entry) => entry && !isLegacyTerminalShimPathEntry(entry)) + .filter( + (entry) => + entry && + !isLegacyTerminalShimPathEntry(entry) && + !namesCapturedShimDir(entry, explicitShimDirs, windows) + ) .join(delimiter) if (cleaned) { env[pathKey] = cleaned diff --git a/src/main/pty/legacy-terminal-windows-tombstone.test.ts b/src/main/pty/legacy-terminal-windows-tombstone.test.ts new file mode 100644 index 00000000000..55a75f5321a --- /dev/null +++ b/src/main/pty/legacy-terminal-windows-tombstone.test.ts @@ -0,0 +1,269 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { resolvePosixTombstoneInterpreter } from './legacy-terminal-posix-tombstone' +import { + __resetLegacyTerminalShimNeutralizationForTests, + neutralizeLegacyTerminalShimDir +} from './legacy-terminal-shim-dir' + +// Why: `expect(text.indexOf(a)).toBeLessThan(text.indexOf(b))` passes when `a` is absent, because +// indexOf returns -1. Every ordering pin must assert both operands exist first. +function expectOrdered(text: string, first: string, second: string): void { + expect(text, `missing: ${first}`).toContain(first) + expect(text, `missing: ${second}`).toContain(second) + expect(text.indexOf(first)).toBeLessThan(text.indexOf(second)) +} + +describe('legacy terminal Windows tombstone text', () => { + const tempRoots: string[] = [] + + const makeUserDataDir = (): string => { + const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-win-')) + tempRoots.push(userData) + return userData + } + + beforeEach(() => { + __resetLegacyTerminalShimNeutralizationForTests() + }) + + afterEach(() => { + for (const tempRoot of tempRoots.splice(0)) { + rmSync(tempRoot, { recursive: true, force: true }) + } + }) + + it('rejects stale Windows real-command paths inside the wrapper directory', () => { + const userData = makeUserDataDir() + const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') + mkdirSync(win32Dir, { recursive: true }) + + neutralizeLegacyTerminalShimDir(userData) + + const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8') + expect(cmd).toContain( + 'if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real="' + ) + // Why: a captured path that no longer exists must be cleared, or the where.exe fallback below + // is skipped and the wrapper execs a missing binary. + expect(cmd).toContain('if defined orca_real if not exist "%orca_real%" set "orca_real="') + expectOrdered(cmd, 'if not exist "%orca_real%"', ':orca_try_candidate') + const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8') + expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase') + expect(powershell).toContain('$realCommand = $null') + expectOrdered( + powershell, + '[StringComparison]::OrdinalIgnoreCase', + 'Test-Path -LiteralPath $realCommand' + ) + }) + + it('emits no percent expression inside a cmd rem comment', () => { + // Why: cmd expands variables inside rem, so a comment mentioning the working directory + // substitutes it into the comment text. Harmless at top level -- verified on Windows 11 that + // rem does not re-parse the result, so a cwd of `C:\\x&pwned&rem` executed nothing -- but rem + // treats separators differently inside a parenthesized block, and this script now has some. + const userData = makeUserDataDir() + const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') + mkdirSync(win32Dir, { recursive: true }) + + neutralizeLegacyTerminalShimDir(userData) + + for (const command of ['git', 'gh'] as const) { + const offending = readFileSync(join(win32Dir, `${command}.cmd`), 'utf8') + .split('\r\n') + .filter((line) => /^\s*rem\b/i.test(line) && line.includes('%')) + expect(offending, `rem comments with percent expressions: ${offending.join(' | ')}`).toEqual( + [] + ) + } + }) + + it('keeps the Windows wrappers ASCII-only', () => { + // Why: cmd.exe seeks through a batch file in bytes but advances by decoded character count, + // so a single multi-byte character shifts every following line. Two em dashes in comments + // made it drop the first four characters of every line and the wrapper died with "The + // syntax of the command is incorrect." Proven on Windows 11; no test caught it. + const userData = makeUserDataDir() + const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') + mkdirSync(win32Dir, { recursive: true }) + + neutralizeLegacyTerminalShimDir(userData) + + for (const name of ['git.cmd', 'gh.cmd', 'git-wrapper.ps1', 'gh-wrapper.ps1']) { + const contents = readFileSync(join(win32Dir, name), 'utf8') + const offending = [...contents].find((character) => character.charCodeAt(0) > 0x7f) + expect(offending, `${name} contains non-ASCII ${JSON.stringify(offending)}`).toBeUndefined() + expect(Buffer.byteLength(contents, 'utf8')).toBe(contents.length) + } + }) + + it('resolves Windows fallbacks against PATH only, never the current directory', () => { + // Why (STA-4169): bare `where.exe git.exe` searches cwd before PATH, so a repository-local + // git.exe/gh.exe could be executed with the user's arguments. + const userData = makeUserDataDir() + const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') + mkdirSync(win32Dir, { recursive: true }) + + neutralizeLegacyTerminalShimDir(userData) + + for (const command of ['git', 'gh'] as const) { + const cmd = readFileSync(join(win32Dir, `${command}.cmd`), 'utf8') + // No where.exe at all: it searches cwd first, so the wrapper walks the cleaned PATH. + expect(cmd).not.toContain('where.exe') + expect(cmd).toContain('for %%P in ("%orca_clean_path:;=" "%") do (') + expect(cmd).toContain(`if exist "%orca_candidate_dir%\\${command}.exe"`) + // Why: %~f preserves a trailing separator; without normalizing, a wrapper-dir entry + // spelled with one escapes self-exclusion and the wrapper tail-loops on itself. The + // separator lives in a variable because a literal backslash before the closing quote + // breaks cmd's parser, and a sentinel character would corrupt paths containing it. + expect(cmd).toContain('set "orca_sep=\\"') + // Why: bare setlocal inherits the caller's delayed-expansion state. Under a parent shell + // with /V:ON, a literal !CD! PATH entry became the cwd and ran a planted git.cmd (exit 66), + // and a legitimate directory containing ! stopped resolving (exit 127). Both on Windows 11. + expect(cmd).toContain('setlocal DisableDelayedExpansion') + expect(cmd).not.toContain('\r\nsetlocal\r\n') + // Why: a captured ORCA_REAL_* may be relative, and both the existence test and the + // invocation resolve it against the cwd. + expect(cmd).toContain('if defined orca_real call :orca_check_rooted') + expect(cmd).toContain('if defined orca_real if not defined orca_rooted set "orca_real="') + // Why: the exported PATH is inherited by the real command; a relative entry left in it lets + // the cwd select the tools that command spawns. + // Why the exact block, not an ordering pin: the first ':orca_append_path' in the file is + // the top-level CALL, and the first 'if not defined orca_rooted' belongs to a different + // subroutine, so an ordering pin stayed green with this subroutine's guard deleted. + expect(cmd).toContain(':orca_append_path\r\nif not defined orca_entry exit /b\r\n') + expect(cmd).toContain( + 'set "orca_probe=%orca_entry%"\r\ncall :orca_check_rooted\r\nif not defined orca_rooted exit /b\r\nfor %%G in ("%orca_entry%") do set "orca_path_entry_dir=%%~fG"' + ) + // Why: CALL re-expands its own command line, so path data passed as an argument gets a + // second round of percent expansion. A PATH entry holding a literal %CD% became the + // current directory before the rooted check saw it and the planted git.cmd ran (rc=66 on + // Windows 11). Every caller must hand the value over in a variable instead. + expect(cmd).not.toContain('call :orca_check_rooted "') + expect(cmd).not.toContain('call :orca_try_candidate "') + expect(cmd).not.toContain('call :orca_append_path "') + expect(cmd).not.toContain('%~1') + expect(cmd).toContain('set "orca_probe=%orca_entry%"\r\ncall :orca_check_rooted\r\n') + // Why: orca_sep is compared against before the legacy dir is normalized; defining it later + // made that strip silently no-op and left the legacy dir on PATH. + expectOrdered(cmd, 'set "orca_sep=', 'if "%orca_legacy_norm:~-1%."') + // Why: %orca_sep% expands before the line is parsed, so `=="%orca_sep%"` becomes `=="\"` — + // the literal trap the variable exists to avoid. Both sides must carry the trailing dot. + expect(cmd).not.toContain('=="%orca_sep%"') + // Why: nothing else asserts the *reject* path, so deleting it would reopen the cwd hijack + // while every accept-path assertion stayed green. + expectOrdered( + cmd, + 'call :orca_check_rooted\r\nif not defined orca_rooted exit /b', + 'for %%G in ("%orca_entry%") do set "orca_candidate_dir=' + ) + expect(cmd).toContain('if not defined orca_rooted exit /b') + expect(cmd).toContain('if "%orca_candidate_dir:~-1%."=="%orca_sep%."') + expect(cmd).not.toContain(':\\#=#%') + // A candidate inside the wrapper directory must still be rejected, compared against the + // cached wrapper dir because %~dp0 is rebound inside a CALL. + expect(cmd).toContain('if /I "%orca_candidate_dir%\\"=="%orca_wrapper_dir%" exit /b') + // Relative entries resolve against the cwd, so they must be rejected like empty ones. + // Why: the rooted-path test must not shell out — an external tool would itself be + // resolved from the cwd, reintroducing the hijack. + expect(cmd).not.toContain('findstr') + expect(cmd).toContain('if "%orca_probe:~1,2%"==":\\" set "orca_rooted=1"') + expect(cmd).toContain('if "%orca_probe:~0,2%"=="\\\\" set "orca_rooted=1"') + // Why: these two guards are the only thing stopping an empty element reaching the cwd on + // Windows; deleting either left every other assertion green. + expect(cmd).toContain('if not defined orca_entry exit /b') + + const powershell = readFileSync(join(win32Dir, `${command}-wrapper.ps1`), 'utf8') + expect(powershell).not.toContain('Get-Command') + expect(powershell).toContain("($env:PATH -split ';')") + // Why: the rooted check must reject drive-relative 'C:foo', which still resolves against + // the cwd — so the IsPathRooted call must be gone, replaced by an explicit prefix match. + expect(powershell).not.toContain('[IO.Path]::IsPathRooted(') + expect(powershell).toContain("$pathEntry -match '^([A-Za-z]:[\\\\/]|\\\\\\\\)'") + expect(powershell).toContain("$realCommand -notmatch '^([A-Za-z]:[\\\\/]|\\\\\\\\)'") + // Why the full pattern, not a prefix: `^([A-Za-z]:|\\\\)` also starts with this and would + // accept drive-relative `C:foo`, which still resolves against the cwd on that drive. + expect(powershell).toContain("-notmatch '^([A-Za-z]:[\\\\/]|\\\\\\\\)'") + // Why: trailing-separator normalization is what makes wrapper self-exclusion work; every + // one of these survived mutation with the suite green. + expect(powershell).toContain("ForEach-Object { $_.TrimEnd('\\', '/') }") + expect(powershell).toContain("$pathEntry.TrimEnd('\\', '/')") + expect(powershell).toContain("$dir.TrimEnd('\\', '/')") + expect(powershell).toContain("$capturedDir.TrimEnd('\\', '/')") + // Why: the fallback loop must skip wrapper dirs, or the wrapper can resolve to itself. + expect(powershell).toContain( + "$dir.TrimEnd('\\', '/'), [StringComparison]::OrdinalIgnoreCase) }) { continue }" + ) + // Why both separators: the rooted-path regex accepts forward slashes, so a wrapper or + // legacy directory spelled with a trailing / missed the lexical exclusion. + expect(powershell).not.toContain("TrimEnd('\\')") + // Why: `C:/Program Files/Git/cmd` style entries are legitimate and must stay accepted. + expect(cmd).toContain('if "%orca_probe:~1,2%"==":/" set "orca_rooted=1"') + // Why: the legacy-dir compare needs the same trailing-separator strip as its twins. + expect(cmd).toContain('if "%orca_legacy_norm:~-1%."=="%orca_sep%."') + // Why: cmd expands a whole line before evaluating `if defined`, so this strip must live in + // a CALL body. Inline, it ran its substring syntax against an unset variable and mangled + // the line into a syntax error. Proven on Windows 11. + expect(cmd).toContain('if defined orca_legacy_wrapper_dir call :orca_normalize_legacy_dir') + expect(cmd).not.toContain('if defined orca_legacy_wrapper_dir for ') + expect(powershell).toContain('if (-not $dir) { continue }') + expect(powershell).toContain('Test-Path -LiteralPath $candidate -PathType Leaf') + } + }) + + it('emits Windows wrappers with CRLF line endings', () => { + // Why: cmd resolves `call :label` by byte offset and that lookup is unreliable in LF-only + // files — the same script worked at 2.4 KB and failed with "cannot find the batch label" + // once it grew past ~3.7 KB. Verified on Windows. + const userData = makeUserDataDir() + const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') + mkdirSync(win32Dir, { recursive: true }) + + neutralizeLegacyTerminalShimDir(userData) + + for (const file of ['git.cmd', 'gh.cmd', 'git-wrapper.ps1', 'gh-wrapper.ps1']) { + const body = readFileSync(join(win32Dir, file), 'utf8') + expect(body, file).toContain('\r\n') + expect(body.replaceAll('\r\n', ''), file).not.toContain('\n') + } + }) + + it('guards both cmd PATH walks so an empty variable cannot break parsing', () => { + // Why: `%VAR:;=" "%` on an empty variable leaves an unbalanced quote that desynchronizes + // cmd parsing for the rest of the file, turning the not-found branch into + // `1>&2 was unexpected at this time.` Reproduced on Windows before this guard. + const userData = makeUserDataDir() + const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') + mkdirSync(win32Dir, { recursive: true }) + + neutralizeLegacyTerminalShimDir(userData) + + for (const command of ['git', 'gh'] as const) { + const cmd = readFileSync(join(win32Dir, `${command}.cmd`), 'utf8') + expect(cmd).toContain('if not defined PATH goto :orca_path_walked') + expect(cmd).toContain('if not defined orca_clean_path goto :orca_candidates_walked') + for (const [guard, loop] of [ + ['if not defined PATH goto :orca_path_walked', 'for %%P in ("%PATH:;='], + [ + 'if not defined orca_clean_path goto :orca_candidates_walked', + 'for %%P in ("%orca_clean_path:;=' + ] + ] as const) { + expectOrdered(cmd, guard, loop) + } + } + }) + + it('never bakes an ambient interpreter lookup on Windows', () => { + // Why: the POSIX tombstone is written on Windows too (Git Bash and WSL panes run it), but no + // absolute candidate exists to a Windows process and PATH there is ';'-separated, so the + // search cannot succeed — without this the fallback reopened the interpreter hijack. + expect(resolvePosixTombstoneInterpreter(undefined, [], 'win32')).toBe('/bin/bash') + expect(resolvePosixTombstoneInterpreter('C:\\Git\\bin;C:\\Windows', [], 'win32')).toBe( + '/bin/bash' + ) + }) +}) diff --git a/src/main/pty/legacy-terminal-windows-tombstone.ts b/src/main/pty/legacy-terminal-windows-tombstone.ts index 8bff863ec18..a11cb9ba52f 100644 --- a/src/main/pty/legacy-terminal-windows-tombstone.ts +++ b/src/main/pty/legacy-terminal-windows-tombstone.ts @@ -1,20 +1,51 @@ // Why: kept beside the POSIX tombstone so the generated wrapper text for every platform lives // in one place, and so the shim-dir module stays under the max-lines limit. +// +// Keep percent signs out of every emitted `rem` line. cmd expands variables inside rem, so a +// comment mentioning %CD% substitutes the working directory into itself. That is harmless at top +// level -- verified on Windows 11 that rem does not re-parse the result, so a cwd of +// `C:\x&pwned&rem` did not execute anything -- but rem handles separators differently inside a +// parenthesized block, and this script now has some. +// +// Keep both templates ASCII-only, comments included. cmd.exe tracks its position in a batch file +// in bytes but advances by decoded character count, so every extra UTF-8 byte shifts the whole +// file: two em dashes in comments made cmd drop the first four characters of every line and the +// script died with "The syntax of the command is incorrect." const WIN32_PASSTHROUGH_WRAPPER = String.raw`@echo off -setlocal +rem Why explicit: bare setlocal inherits the caller's delayed-expansion state, and a parent shell +rem started with /V:ON made every PATH entry undergo ! expansion inside the loops below. A literal +rem !CD! entry then became the current directory and a planted git.cmd ran (exit 66), and a +rem legitimate directory whose name contains ! stopped resolving (exit 127). Both proven on +rem Windows 11; both disappear when the wrapper pins its own state. +setlocal DisableDelayedExpansion set "orca_real=%ORCA_REAL___ORCA_UPPER_COMMAND__%" set "orca_wrapper_dir=%~dp0" set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%" set "orca_clean_path=" rem Why: holds a single separator so the trailing-separator tests below need neither a literal rem backslash before a quote (which breaks cmd parsing) nor a sentinel character (which would -rem corrupt any path containing it). +rem corrupt any path containing it). Comparisons append a dot so the separator is never +rem adjacent to a closing quote, which would break the parser exactly as a literal would. set "orca_sep=\" +rem Why a subroutine, not an "if defined ... " one-liner: cmd expands a whole line before it +rem evaluates the condition, so the substring syntax below still runs against an unset variable +rem and leaves the line mangled. A CALL body is only parsed once it is reached. +rem Why here: the value cannot change mid-run, so normalize once rather than per PATH entry. +set "orca_legacy_norm=" +if defined orca_legacy_wrapper_dir call :orca_normalize_legacy_dir rem Why: an empty PATH leaves the substitution below with an unbalanced quote, which rem desynchronizes cmd parsing for the rest of the file. Skip the line entirely instead. if not defined PATH goto :orca_path_walked -for %%P in ("%PATH:;=" "%") do call :orca_append_path "%%~P" +rem Why the variable: CALL re-expands its own command line, so a PATH entry naming the current +rem directory through a percent expression would become that directory before the rooted check +rem below ever saw it, and the cwd would then be searched for __ORCA_COMMAND__. Proven on +rem Windows 11. The expression is spelled out only in the TypeScript comment above: cmd expands +rem percent signs inside rem, so writing one here would substitute a path into the comment. +for %%P in ("%PATH:;=" "%") do ( + set "orca_entry=%%~P" + call :orca_append_path +) :orca_path_walked set "PATH=%orca_clean_path%" set "ORCA_ENABLE_GIT_ATTRIBUTION=" @@ -26,13 +57,21 @@ set "ORCA_ATTRIBUTION_BYPASS=" set "ORCA_REAL_GIT=" set "ORCA_REAL_GH=" if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real=" +rem Why: a captured path may be relative, and both "if exist" and the invocation resolve it +rem against the current directory, so an inherited .\__ORCA_COMMAND__.exe would run from the repo. +set "orca_probe=%orca_real%" +if defined orca_real call :orca_check_rooted +if defined orca_real if not defined orca_rooted set "orca_real=" rem Why: clear a captured path that no longer exists, or the PATH walk below is skipped. if defined orca_real if not exist "%orca_real%" set "orca_real=" if defined orca_real goto run rem Why: an unqualified Windows command lookup searches the current directory before PATH, so a rem repository-local __ORCA_COMMAND__.exe would win. Walk the cleaned PATH ourselves instead. if not defined orca_clean_path goto :orca_candidates_walked -for %%P in ("%orca_clean_path:;=" "%") do call :orca_try_candidate "%%~P" +for %%P in ("%orca_clean_path:;=" "%") do ( + set "orca_entry=%%~P" + call :orca_try_candidate +) :orca_candidates_walked if not defined orca_real ( echo Orca compatibility wrapper could not locate __ORCA_COMMAND__ on PATH. 1>&2 @@ -42,23 +81,44 @@ if not defined orca_real ( "%orca_real%" %* exit /b %ERRORLEVEL% +:orca_normalize_legacy_dir +rem Why the rooted test first: full-path expansion resolves a relative value against the current +rem directory, so a +rem relative ORCA_ATTRIBUTION_SHIM_DIR would let the cwd decide which PATH entry counts as the +rem legacy directory and get a legitimate one skipped. Leaving the normalized value unset makes +rem the reject subroutine below a no-op, which is the safe outcome. +set "orca_probe=%orca_legacy_wrapper_dir%" +call :orca_check_rooted +if not defined orca_rooted exit /b +for %%G in ("%orca_legacy_wrapper_dir%") do set "orca_legacy_norm=%%~fG" +rem Why: full-path expansion preserves a trailing separator; normalize before comparing. +if "%orca_legacy_norm:~-1%."=="%orca_sep%." set "orca_legacy_norm=%orca_legacy_norm:~0,-1%" +exit /b + +:orca_check_rooted +rem Why: tested in pure batch on purpose. An external tool invoked here would itself be resolved +rem from the current directory, reintroducing the very hijack this guard exists to prevent. +rem Why orca_probe rather than an argument: see the CALL re-expansion note above. +set "orca_rooted=" +rem Why: an unset probe would leave the substring syntax below unexpanded and mangle the line. +if not defined orca_probe exit /b +if "%orca_probe:~0,2%"=="\\" set "orca_rooted=1" +if "%orca_probe:~1,2%"==":\" set "orca_rooted=1" +if "%orca_probe:~1,2%"==":/" set "orca_rooted=1" +exit /b + :orca_try_candidate if defined orca_real exit /b -if "%~1"=="" exit /b +if not defined orca_entry exit /b rem Why: a relative entry resolves against the current directory, same exposure as an empty one. -rem Tested in pure batch on purpose: an external tool invoked here would itself be resolved from -rem the current directory, reintroducing the very hijack this guard exists to prevent. -set "orca_candidate=%~1" -if "%orca_candidate:~0,2%"=="\\" goto orca_candidate_rooted -if "%orca_candidate:~1,2%"==":\" goto orca_candidate_rooted -if "%orca_candidate:~1,2%"==":/" goto orca_candidate_rooted -exit /b -:orca_candidate_rooted -for %%G in ("%~1") do set "orca_candidate_dir=%%~fG" +set "orca_probe=%orca_entry%" +call :orca_check_rooted +if not defined orca_rooted exit /b +for %%G in ("%orca_entry%") do set "orca_candidate_dir=%%~fG" rem Why: full-path expansion preserves a trailing separator, so without normalizing, the rem self-exclusion below misses a wrapper-dir entry spelled with one and the wrapper resolves rem to itself, looping forever. -if "%orca_candidate_dir:~-1%"=="%orca_sep%" set "orca_candidate_dir=%orca_candidate_dir:~0,-1%" +if "%orca_candidate_dir:~-1%."=="%orca_sep%." set "orca_candidate_dir=%orca_candidate_dir:~0,-1%" rem Why: the script-dir operator is rebound to this label inside CALL, so compare against the rem cached wrapper dir captured at top level. if /I "%orca_candidate_dir%\"=="%orca_wrapper_dir%" exit /b @@ -68,20 +128,27 @@ if not defined orca_real if exist "%orca_candidate_dir%\__ORCA_COMMAND__.bat" se exit /b :orca_append_path -for %%G in ("%~1") do set "orca_path_entry_dir=%%~fG" +if not defined orca_entry exit /b +rem Why: the exported PATH is inherited by the real git and anything it spawns, so a relative +rem entry left here lets the current directory select those tools instead. +set "orca_probe=%orca_entry%" +call :orca_check_rooted +if not defined orca_rooted exit /b +for %%G in ("%orca_entry%") do set "orca_path_entry_dir=%%~fG" rem Why: full-path expansion preserves a trailing separator; normalize before comparing. -if "%orca_path_entry_dir:~-1%"=="%orca_sep%" set "orca_path_entry_dir=%orca_path_entry_dir:~0,-1%" +if "%orca_path_entry_dir:~-1%."=="%orca_sep%." set "orca_path_entry_dir=%orca_path_entry_dir:~0,-1%" set "orca_path_entry_dir=%orca_path_entry_dir%\" if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b set "orca_skip_entry=" if defined orca_legacy_wrapper_dir call :orca_reject_legacy_dir if defined orca_skip_entry exit /b -if defined orca_clean_path (set "orca_clean_path=%orca_clean_path%;%~1") else set "orca_clean_path=%~1" +if defined orca_clean_path (set "orca_clean_path=%orca_clean_path%;%orca_entry%") else set "orca_clean_path=%orca_entry%" exit /b :orca_reject_legacy_dir -for %%G in ("%orca_legacy_wrapper_dir%") do set "orca_legacy_norm=%%~fG" -if "%orca_legacy_norm:~-1%"=="%orca_sep%" set "orca_legacy_norm=%orca_legacy_norm:~0,-1%" +rem Why: an unrooted legacy dir leaves this unset, and comparing against a bare separator could +rem only ever misfire. +if not defined orca_legacy_norm exit /b if /I "%orca_path_entry_dir%"=="%orca_legacy_norm%\" set "orca_skip_entry=1" exit /b ` @@ -91,18 +158,23 @@ $commandName = '__ORCA_COMMAND__' $realCommand = [Environment]::GetEnvironmentVariable('ORCA_REAL___ORCA_UPPER_COMMAND__') $wrapperDir = Split-Path -Parent $MyInvocation.MyCommand.Path $legacyWrapperDir = $env:ORCA_ATTRIBUTION_SHIM_DIR -$wrapperDirs = @($wrapperDir, $legacyWrapperDir) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') } +# Why both separators: the rooted-path test below accepts forward slashes, so a directory +# spelled with a trailing / would miss this lexical exclusion and the wrapper could recurse. +$wrapperDirs = @($wrapperDir, $legacyWrapperDir) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') } $env:PATH = (($env:PATH -split ';') | Where-Object { $pathEntry = $_ - $pathEntry -and -not ($wrapperDirs | Where-Object { - [string]::Equals($_, $pathEntry.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase) + # Why: the exported PATH is inherited by the real command and anything it spawns. + $pathEntry -and ($pathEntry -match '^([A-Za-z]:[\\/]|\\\\)') -and -not ($wrapperDirs | Where-Object { + [string]::Equals($_, $pathEntry.TrimEnd('\', '/'), [StringComparison]::OrdinalIgnoreCase) }) }) -join ';' 'ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_GIT_COMMIT_TRAILER', 'ORCA_GH_PR_FOOTER', 'ORCA_GH_ISSUE_FOOTER', 'ORCA_ATTRIBUTION_SHIM_DIR', 'ORCA_REAL_GIT', 'ORCA_REAL_GH', 'ORCA_ATTRIBUTION_BYPASS' | ForEach-Object { Remove-Item "Env:$_" -ErrorAction SilentlyContinue } +# Why: a captured value may be relative, and Test-Path plus invocation resolve it against the cwd. +if ($realCommand -and $realCommand -notmatch '^([A-Za-z]:[\\/]|\\\\)') { $realCommand = $null } if ($realCommand) { try { $capturedDir = Split-Path -Parent ([IO.Path]::GetFullPath($realCommand)) - if ([string]::Equals($capturedDir.TrimEnd('\'), $wrapperDir.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) { + if ([string]::Equals($capturedDir.TrimEnd('\', '/'), $wrapperDir.TrimEnd('\', '/'), [StringComparison]::OrdinalIgnoreCase)) { $realCommand = $null } } catch { @@ -120,7 +192,7 @@ if (-not $realCommand -or -not (Test-Path -LiteralPath $realCommand)) { # current directory on that drive. IsPathFullyQualified is absent on Windows PowerShell 5.1, # so match the same prefixes the cmd wrapper accepts. if ($dir -notmatch '^([A-Za-z]:[\\/]|\\\\)') { continue } - if ($wrapperDirs | Where-Object { [string]::Equals($_, $dir.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase) }) { continue } + if ($wrapperDirs | Where-Object { [string]::Equals($_, $dir.TrimEnd('\', '/'), [StringComparison]::OrdinalIgnoreCase) }) { continue } foreach ($ext in @('.exe', '.cmd', '.bat')) { $candidate = Join-Path $dir "$commandName$ext" if (Test-Path -LiteralPath $candidate -PathType Leaf) { $realCommand = $candidate; break } diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index a62e043c1f7..916d11dfd67 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -47,7 +47,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_TERMINAL_HANDLE: 'term_wsl', ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_CLI_COMMAND: 'orca-ide', - ORCA_CODEX_LAUNCH_PREFLIGHT: 'orca', + ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1', diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index de78d21b10b..608599f4a67 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -2,6 +2,7 @@ authorization, and watcher lifecycle fixtures; splitting would duplicate the setup that makes cross-command filesystem behavior comparable. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FileReadCapExceededError, StreamProtocolError } from '../ssh/ssh-filesystem-stream-reader' import { EventEmitter } from 'node:events' import { link, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -104,7 +105,12 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' })) -import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files' +import { + awaitRuntimeFileWatcherUnsubscribes, + RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES, + RuntimeFileCommands +} from './orca-runtime-files' +import { REMOTE_RPC_MAX_CONTENT_BYTES } from '../../shared/remote-rpc-content-budget' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resetSshConnectionGenerations, @@ -2326,6 +2332,30 @@ describe('RuntimeFileCommands', () => { ).rejects.toThrow('terminal_file_grant_stale') }) + it('keeps local terminal artifact previews above the remote cap available', async () => { + const size = RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES + 1 + const artifactPath = await tempFile('result.png', 'a'.repeat(size)) + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + + await expect( + commands.readTerminalArtifactPreview( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).resolves.toMatchObject({ + content: Buffer.alloc(size, 0x61).toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + }) + it('rejects binary-extension terminal artifacts from the editable text path', async () => { const artifactPath = await tempFile('report.pdf', '%PDF text-looking bytes') const { commands } = createRuntimeFileCommands({ path: '/repo' }) @@ -2494,6 +2524,28 @@ describe('RuntimeFileCommands', () => { expect(readTerminalArtifact).not.toHaveBeenCalled() }) + it('rejects additive remote terminal preview fields beyond the request budget', async () => { + const { commands, readTerminalArtifact } = + createRemoteTerminalArtifactGrantFixture('/tmp/result.png') + const result = await resolveTerminalArtifactPath(commands, '/tmp/result.png') + const target = absoluteFileTarget(result) + readTerminalArtifact.mockResolvedValue({ + content: 'a', + isBinary: true, + futureMetadata: 'x'.repeat(128) + }) + + await expect( + commands.readTerminalArtifactPreview( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a', + 128 + ) + ).rejects.toThrow('file_too_large') + }) + it('rejects remote terminal artifact writes when a grant no longer resolves to the granted path', async () => { const { commands, readTerminalArtifact, writeTerminalArtifact, moveArtifactTarget } = createRemoteTerminalArtifactGrantFixture() @@ -2581,4 +2633,189 @@ describe('RuntimeFileCommands', () => { ) }) }) + + // Why: mobile opens every image tab through files.readPreview, so this constant is the most + // reachable way to overflow the outbound envelope and kill the socket. + describe('previewable binary budget', () => { + const previewTempDirs: string[] = [] + + afterEach(async () => { + await Promise.all(previewTempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + previewTempDirs.length = 0 + }) + + async function previewFixture(size = Buffer.byteLength('fake-png')): Promise { + const dir = await mkdtemp(join(tmpdir(), 'orca-preview-budget-')) + previewTempDirs.push(dir) + await writeFile(join(dir, 'logo.png'), Buffer.alloc(size, 0x61)) + return dir + } + + it('stays inside the transport ceiling once base64-inflated', () => { + const result = { + content: Buffer.alloc(RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES).toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png' + } + + expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThanOrEqual( + REMOTE_RPC_MAX_CONTENT_BYTES + ) + }) + + it('rejects a previewable image one byte above the cap', async () => { + const dir = await previewFixture(RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES + 1) + const { commands } = createRuntimeFileCommands({ path: dir }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + + await expect( + commands.readFileExplorerPreview('id:wt-1', 'logo.png', REMOTE_RPC_MAX_CONTENT_BYTES) + ).rejects.toThrow('file_too_large') + }) + + it('returns full base64 for a previewable image at the cap', async () => { + const dir = await previewFixture(RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) + const { commands } = createRuntimeFileCommands({ path: dir }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + + await expect( + commands.readFileExplorerPreview('id:wt-1', 'logo.png', REMOTE_RPC_MAX_CONTENT_BYTES) + ).resolves.toEqual({ + content: Buffer.alloc(RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES, 0x61).toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + }) + + it('keeps local previews above the remote cap available without a request budget', async () => { + const size = RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES + 1 + const dir = await previewFixture(size) + const { commands } = createRuntimeFileCommands({ path: dir }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + + await expect(commands.readFileExplorerPreview('id:wt-1', 'logo.png')).resolves.toEqual({ + content: Buffer.alloc(size, 0x61).toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + }) + + it('rejects an SSH text preview past the decoded text limit the local branch enforces', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + // NUL-free control bytes: sniffed as text, yet each escapes to six JSON bytes. + const content = '\u0001'.repeat(1024 * 1024) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn().mockResolvedValue({ type: 'file', size: content.length }), + readFile: vi.fn().mockResolvedValue({ content, isBinary: false }) + } as never) + + await expect(commands.readFileExplorerPreview('id:wt-1', 'log.txt')).rejects.toThrow( + 'file_too_large' + ) + }) + + it('still returns an SSH binary preview inside the base64 cap', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const preview = { content: 'a'.repeat(1024 * 1024), isBinary: true, isImage: true } + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi + .fn() + .mockResolvedValue({ type: 'file', size: RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES }), + readFile: vi.fn().mockResolvedValue(preview) + } as never) + + await expect(commands.readFileExplorerPreview('id:wt-1', 'logo.png')).resolves.toEqual( + preview + ) + }) + + it('rejects an SSH binary result that grew past its request-scoped budget', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const readFile = vi.fn().mockResolvedValue({ + content: 'a'.repeat(13), + isBinary: true, + isImage: true + }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn().mockResolvedValue({ type: 'file', size: 0 }), + readFile + } as never) + + await expect(commands.readFileExplorerPreview('id:wt-1', 'logo.png', 12)).rejects.toThrow( + 'file_too_large' + ) + expect(readFile).toHaveBeenCalledWith('/repo/logo.png', { + maxBinaryBytes: 0, + maxTextBytes: 512 * 1024 + }) + }) + + it('rejects escape-dense SSH text beyond the request-scoped result budget', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn().mockResolvedValue({ type: 'file', size: 64 }), + readFile: vi.fn().mockResolvedValue({ content: '\u0001'.repeat(64), isBinary: false }) + } as never) + + await expect(commands.readFileExplorerPreview('id:wt-1', 'log.txt', 128)).rejects.toThrow( + 'file_too_large' + ) + }) + + // Why: without translation the reader's raw "exceeds client cap" string reaches the client as a + // generic runtime_error, which neither the desktop nor the mobile preview arm recognizes. + it('translates an over-cap stream read into file_too_large', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn().mockResolvedValue({ type: 'file', size: 1024 }), + readFile: vi + .fn() + .mockRejectedValue( + new FileReadCapExceededError('Reported totalSize 900000 exceeds client cap 524288') + ) + } as never) + + await expect(commands.readFileExplorerPreview('id:wt-1', 'log.txt')).rejects.toThrow( + 'file_too_large' + ) + }) + + it('leaves a genuine stream protocol failure unmasked', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn().mockResolvedValue({ type: 'file', size: 1024 }), + readFile: vi.fn().mockRejectedValue(new StreamProtocolError('Malformed chunk for stream 4')) + } as never) + + await expect(commands.readFileExplorerPreview('id:wt-1', 'log.txt')).rejects.toThrow( + 'Malformed chunk' + ) + }) + + it('rejects oversized SSH preview metadata with small content', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn().mockResolvedValue({ type: 'file', size: 1 }), + readFile: vi.fn().mockResolvedValue({ + content: 'a', + isBinary: true, + mimeType: 'x'.repeat(128) + }) + } as never) + + await expect(commands.readFileExplorerPreview('id:wt-1', 'logo.png', 128)).rejects.toThrow( + 'file_too_large' + ) + }) + }) }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 826f8a9e2d3..c99b3e8a5e9 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -10,7 +10,6 @@ import { lstat, mkdir, open, - readFile, readdir, rename, realpath, @@ -31,6 +30,10 @@ import { relativePathInsideRoot, resolveRuntimePath } from '../../shared/cross-platform-path' +import { + REMOTE_RPC_MAX_CONTENT_BYTES, + remoteRpcResultExceedsContentBudget +} from '../../shared/remote-rpc-content-budget' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { sortDirEntries } from '../../shared/file-name-sort' import type { @@ -76,7 +79,8 @@ import { onSshFilesystemProviderRegistered, SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' -import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { FileReadLimits, FileStat, IFilesystemProvider } from '../providers/types' +import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' import { isWatcherProcessFailure, WatcherProcessFailure @@ -90,13 +94,68 @@ import { beginWatcherInstall } from '../ipc/watcher-removal-gate' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/node-bounded-file-reader' const MOBILE_FILE_LIST_LIMIT = 5000 const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 const MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES = 8 const MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS = 30_000 const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 -const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 +const LOCAL_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 +const PREVIEWABLE_BINARY_EMPTY_RESULT_BYTES = Buffer.byteLength( + JSON.stringify({ + content: '', + isBinary: true, + isImage: true, + mimeType: 'application/octet-stream' + }), + 'utf8' +) +const PREVIEW_CONTENT_FIELDS = ['content'] as const + +function previewableBinaryByteLimit(maxContentBytes: number): number { + const base64Bytes = Math.max(0, maxContentBytes - PREVIEWABLE_BINARY_EMPTY_RESULT_BYTES) + return Math.floor(base64Bytes / 4) * 3 +} + +// Why: the stream reader aborts an over-cap read with a raw protocol message; clients key on +// `file_too_large`, so translate it here rather than surfacing internal stream wording. +async function readPreviewFileWithinCap( + provider: IFilesystemProvider, + filePath: string, + limits: FileReadLimits +): Promise { + try { + return await provider.readFile(filePath, limits) + } catch (error) { + if (error instanceof FileReadCapExceededError) { + throw new Error('file_too_large') + } + throw error + } +} + +function assertPreviewWithinTransportBudget( + result: RuntimeFilePreviewResult, + maxContentBytes: number | undefined +): RuntimeFilePreviewResult { + if ( + maxContentBytes !== undefined && + remoteRpcResultExceedsContentBudget(result, maxContentBytes, PREVIEW_CONTENT_FIELDS) + ) { + throw new Error('file_too_large') + } + return result +} + +// Why: previews are reachable only over RPC and base64 inflates them 4/3, so derive the cap from the +// transport ceiling — a hardcoded 10 MiB serializes past the outbound envelope and kills the socket. +export const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = previewableBinaryByteLimit( + REMOTE_RPC_MAX_CONTENT_BYTES +) const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 export const WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS = 10_000 const TERMINAL_FILE_GRANT_TTL_MS = 10 * 60 * 1000 @@ -1159,7 +1218,8 @@ export class RuntimeFileCommands { worktreeSelector: string, grantId: string, absolutePath: string, - clientId?: string + clientId?: string, + maxContentBytes?: number ): Promise { const { grant } = await this.requireTerminalFileGrant( worktreeSelector, @@ -1170,13 +1230,20 @@ export class RuntimeFileCommands { if (grant.connectionId) { const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant) this.refreshTerminalFileGrant(grant) - return this.readRemoteTerminalArtifactPreview(provider, grant) + return assertPreviewWithinTransportBudget( + await this.readRemoteTerminalArtifactPreview(provider, grant, maxContentBytes), + maxContentBytes + ) } const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) try { - const preview = await readLocalTerminalArtifactPreviewFromHandle(handle, grant) + const preview = await readLocalTerminalArtifactPreviewFromHandle( + handle, + grant, + maxContentBytes + ) this.refreshTerminalFileGrant(grant) - return preview + return assertPreviewWithinTransportBudget(preview, maxContentBytes) } finally { await handle.close() } @@ -1273,19 +1340,27 @@ export class RuntimeFileCommands { private async readRemoteTerminalArtifactPreview( provider: IFilesystemProvider, - grant: TerminalFileGrant + grant: TerminalFileGrant, + maxContentBytes: number | undefined ): Promise { - const preview = await this.readRemoteTerminalArtifact( - provider, - grant, - RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES - ) + const binaryMaxBytes = + maxContentBytes === undefined + ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES + : previewableBinaryByteLimit(maxContentBytes) + const preview = await this.readRemoteTerminalArtifact(provider, grant, binaryMaxBytes) if ( !preview.isBinary && Buffer.byteLength(preview.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES ) { throw new Error('file_too_large') } + if ( + preview.isBinary && + maxContentBytes !== undefined && + Buffer.byteLength(preview.content, 'utf8') > maxContentBytes + ) { + throw new Error('file_too_large') + } return preview } @@ -1496,8 +1571,13 @@ export class RuntimeFileCommands { async readFileExplorerPreview( worktreeSelector: string, - relativePath: string + relativePath: string, + maxContentBytes?: number ): Promise { + const binaryMaxBytes = + maxContentBytes === undefined + ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES + : previewableBinaryByteLimit(maxContentBytes) const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null if (target.connectionId) { @@ -1505,37 +1585,62 @@ export class RuntimeFileCommands { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } const fileStats = await provider.stat(target.path) - if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) { + if (fileStats.size > binaryMaxBytes) { throw new Error('file_too_large') } - const result = await provider.readFile(target.path) - return result + const result = await readPreviewFileWithinCap(provider, target.path, { + maxBinaryBytes: binaryMaxBytes, + maxTextBytes: MOBILE_FILE_READ_MAX_BYTES + }) + // Why: the stat gate sizes base64 binaries; text crosses the wire JSON-escaped (up to 6x), so + // hold it to the same decoded limit the local branch enforces before reading. + if ( + !result.isBinary && + Buffer.byteLength(result.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES + ) { + throw new Error('file_too_large') + } + if ( + result.isBinary && + maxContentBytes !== undefined && + Buffer.byteLength(result.content, 'utf8') > maxContentBytes + ) { + throw new Error('file_too_large') + } + return assertPreviewWithinTransportBudget(result, maxContentBytes) } const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const fileStats = await stat(filePath) const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - if (mimeType) { - if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) { + const maxBytes = mimeType ? binaryMaxBytes : MOBILE_FILE_READ_MAX_BYTES + let buffer: Buffer + try { + buffer = (await readNodeFileWithinLimit(filePath, maxBytes)).buffer + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { throw new Error('file_too_large') } - const buffer = await readFile(filePath) - return { - content: buffer.toString('base64'), - isBinary: true, - isImage: true, - mimeType - } + throw error + } + if (mimeType) { + return assertPreviewWithinTransportBudget( + { + content: buffer.toString('base64'), + isBinary: true, + isImage: true, + mimeType + }, + maxContentBytes + ) } - if (fileStats.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - const buffer = await readFile(filePath) if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } + return assertPreviewWithinTransportBudget({ content: '', isBinary: true }, maxContentBytes) } - return { content: buffer.toString('utf-8'), isBinary: false } + return assertPreviewWithinTransportBudget( + { content: buffer.toString('utf-8'), isBinary: false }, + maxContentBytes + ) } async readFileExplorerChunk( @@ -2386,7 +2491,8 @@ async function readLocalTerminalArtifactFileFromHandle( async function readLocalTerminalArtifactPreviewFromHandle( handle: FileHandle, - grant: TerminalFileGrant + grant: TerminalFileGrant, + maxContentBytes: number | undefined ): Promise { const fileStats = await handle.stat() if (fileStats.isDirectory()) { @@ -2395,13 +2501,17 @@ async function readLocalTerminalArtifactPreviewFromHandle( assertTerminalFileGrantFresh(grant, fileStats) const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(grant.absolutePath).toLowerCase()] if (mimeType) { - if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) { + const binaryMaxBytes = + maxContentBytes === undefined + ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES + : previewableBinaryByteLimit(maxContentBytes) + if (fileStats.size > binaryMaxBytes) { + throw new Error('file_too_large') + } + const buffer = await readFileHandleBufferBounded(handle, binaryMaxBytes + 1) + if (buffer.byteLength > binaryMaxBytes) { throw new Error('file_too_large') } - const buffer = await readFileHandleBufferBounded( - handle, - RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES + 1 - ) return { content: buffer.toString('base64'), isBinary: true, diff --git a/src/main/runtime/orca-runtime-git-diff-budget.test.ts b/src/main/runtime/orca-runtime-git-diff-budget.test.ts new file mode 100644 index 00000000000..2e30f7f931e --- /dev/null +++ b/src/main/runtime/orca-runtime-git-diff-budget.test.ts @@ -0,0 +1,198 @@ +// Why: the cap lives in orca-runtime-git.ts so both branches of all three diff readers are covered — +// an SSH host forwards its provider's payload verbatim, so an older relay cannot be relied on to clamp it. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { REMOTE_RPC_MAX_CONTENT_BYTES } from '../../shared/remote-rpc-content-budget' +import type { GitDiffResult } from '../../shared/git-diff-compare-types' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type * as GitStatusModule from '../git/status' +import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' + +const mocks = vi.hoisted(() => ({ + getSshGitProvider: vi.fn(), + getDiff: vi.fn(), + getBranchDiff: vi.fn(), + getCommitDiff: vi.fn() +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: mocks.getSshGitProvider +})) + +vi.mock('../git/status', async () => ({ + ...(await vi.importActual('../git/status')), + getDiff: mocks.getDiff, + getBranchDiff: mocks.getBranchDiff, + getCommitDiff: mocks.getCommitDiff +})) + +const OVERSIZED_BASE64 = 'A'.repeat(REMOTE_RPC_MAX_CONTENT_BYTES + 1) +const BRANCH_COMPARE = { mergeBase: 'base-oid', headOid: 'head-oid' } +const COMMIT_ARGS = { + commitOid: 'commit-oid', + parentOid: 'parent-oid', + filePath: 'assets/logo.png' +} +const TOO_LARGE = { code: 'diff_too_large', data: { maxBytes: REMOTE_RPC_MAX_CONTENT_BYTES } } + +function oversizedResult(): GitDiffResult { + return { + kind: 'binary', + originalContent: '', + modifiedContent: OVERSIZED_BASE64, + originalIsBinary: false, + modifiedIsBinary: true + } +} + +function commands(connectionId?: string): RuntimeGitCommands { + const worktree = { + id: 'wt-1', + repoId: 'repo-1', + path: '/remote/repo', + git: { path: '/remote/repo', branch: 'main', isBare: false, isMainWorktree: false } + } as unknown as ResolvedRuntimeGitWorktree + return new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree, + ...(connectionId ? { connectionId } : {}) + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) +} + +function sshProvider(): { + getDiff: ReturnType + getBranchDiff: ReturnType + getCommitDiff: ReturnType +} { + return { + getDiff: vi.fn().mockResolvedValue(oversizedResult()), + getBranchDiff: vi.fn().mockResolvedValue([oversizedResult()]), + getCommitDiff: vi.fn().mockResolvedValue(oversizedResult()) + } +} + +describe('runtime git diff transport budget', () => { + beforeEach(() => { + mocks.getSshGitProvider.mockReset() + mocks.getDiff.mockReset().mockResolvedValue(oversizedResult()) + mocks.getBranchDiff.mockReset().mockResolvedValue(oversizedResult()) + mocks.getCommitDiff.mockReset().mockResolvedValue(oversizedResult()) + }) + + it('caps an SSH-forwarded diff that exceeds the budget', async () => { + const provider = sshProvider() + mocks.getSshGitProvider.mockReturnValue(provider) + + await expect( + commands('conn-1').getRuntimeGitDiff( + 'id:wt-1', + 'assets/logo.png', + false, + undefined, + REMOTE_RPC_MAX_CONTENT_BYTES + ) + ).rejects.toMatchObject(TOO_LARGE) + expect(provider.getDiff).toHaveBeenCalledWith( + '/remote/repo', + 'assets/logo.png', + false, + undefined + ) + expect(mocks.getDiff).not.toHaveBeenCalled() + }) + + it('leaves an SSH-forwarded diff uncapped when no budget is supplied', async () => { + mocks.getSshGitProvider.mockReturnValue(sshProvider()) + + await expect( + commands('conn-1').getRuntimeGitDiff('id:wt-1', 'assets/logo.png', false) + ).resolves.toMatchObject({ modifiedContent: OVERSIZED_BASE64 }) + }) + + it('caps a local-repo diff that exceeds the budget', async () => { + await expect( + commands().getRuntimeGitDiff( + 'id:wt-1', + 'assets/logo.png', + false, + undefined, + REMOTE_RPC_MAX_CONTENT_BYTES + ) + ).rejects.toMatchObject(TOO_LARGE) + expect(mocks.getDiff).toHaveBeenCalled() + expect(mocks.getSshGitProvider).not.toHaveBeenCalled() + }) + + it('leaves a local-repo diff uncapped when no budget is supplied', async () => { + await expect( + commands().getRuntimeGitDiff('id:wt-1', 'assets/logo.png', false) + ).resolves.toMatchObject({ modifiedContent: OVERSIZED_BASE64 }) + }) + + it('caps an SSH-forwarded branch diff that exceeds the budget', async () => { + const provider = sshProvider() + mocks.getSshGitProvider.mockReturnValue(provider) + + await expect( + commands('conn-1').getRuntimeGitBranchDiff( + 'id:wt-1', + BRANCH_COMPARE, + 'assets/logo.png', + undefined, + REMOTE_RPC_MAX_CONTENT_BYTES + ) + ).rejects.toMatchObject(TOO_LARGE) + expect(provider.getBranchDiff).toHaveBeenCalled() + expect(mocks.getBranchDiff).not.toHaveBeenCalled() + }) + + it('caps a local-repo branch diff that exceeds the budget', async () => { + await expect( + commands().getRuntimeGitBranchDiff( + 'id:wt-1', + BRANCH_COMPARE, + 'assets/logo.png', + undefined, + REMOTE_RPC_MAX_CONTENT_BYTES + ) + ).rejects.toMatchObject(TOO_LARGE) + expect(mocks.getBranchDiff).toHaveBeenCalled() + }) + + it('leaves a local-repo branch diff uncapped when no budget is supplied', async () => { + await expect( + commands().getRuntimeGitBranchDiff('id:wt-1', BRANCH_COMPARE, 'assets/logo.png') + ).resolves.toMatchObject({ modifiedContent: OVERSIZED_BASE64 }) + }) + + it('caps an SSH-forwarded commit diff that exceeds the budget', async () => { + const provider = sshProvider() + mocks.getSshGitProvider.mockReturnValue(provider) + + await expect( + commands('conn-1').getRuntimeGitCommitDiff( + 'id:wt-1', + COMMIT_ARGS, + REMOTE_RPC_MAX_CONTENT_BYTES + ) + ).rejects.toMatchObject(TOO_LARGE) + expect(provider.getCommitDiff).toHaveBeenCalled() + expect(mocks.getCommitDiff).not.toHaveBeenCalled() + }) + + it('caps a local-repo commit diff that exceeds the budget', async () => { + await expect( + commands().getRuntimeGitCommitDiff('id:wt-1', COMMIT_ARGS, REMOTE_RPC_MAX_CONTENT_BYTES) + ).rejects.toMatchObject(TOO_LARGE) + expect(mocks.getCommitDiff).toHaveBeenCalled() + }) + + it('leaves a local-repo commit diff uncapped when no budget is supplied', async () => { + await expect(commands().getRuntimeGitCommitDiff('id:wt-1', COMMIT_ARGS)).resolves.toMatchObject( + { + modifiedContent: OVERSIZED_BASE64 + } + ) + }) +}) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 3d802d219ea..c90f6cfef27 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -16,6 +16,7 @@ import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' import type { GitPushTarget, GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' +import { assertGitDiffWithinTransportBudget } from '../../shared/git-diff-transport-budget' import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { @@ -341,11 +342,14 @@ export class RuntimeGitCommands { return listLocalBranches(target.worktree.path, localGitOptionsForTarget(target)) } + // Why: the budget is enforced here, after both branches, so an SSH payload forwarded verbatim from + // an older relay is capped too. async getRuntimeGitDiff( worktreeSelector: string, filePath: string, staged: boolean, - compareAgainstHead?: boolean + compareAgainstHead?: boolean, + maxContentBytes?: number ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const relativePath = normalizeRuntimeGitRelativePath(filePath) @@ -354,14 +358,20 @@ export class RuntimeGitCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.getDiff(target.worktree.path, relativePath, staged, compareAgainstHead) + return assertGitDiffWithinTransportBudget( + await provider.getDiff(target.worktree.path, relativePath, staged, compareAgainstHead), + maxContentBytes + ) } - return getDiff( - target.worktree.path, - relativePath, - staged, - compareAgainstHead, - localGitOptionsForTarget(target) + return assertGitDiffWithinTransportBudget( + await getDiff( + target.worktree.path, + relativePath, + staged, + compareAgainstHead, + localGitOptionsForTarget(target) + ), + maxContentBytes ) } @@ -522,7 +532,8 @@ export class RuntimeGitCommands { worktreeSelector: string, compare: { mergeBase: string; headOid: string }, filePath: string, - oldPath?: string + oldPath?: string, + maxContentBytes?: number ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const relativePath = normalizeRuntimeGitRelativePath(filePath) @@ -538,31 +549,36 @@ export class RuntimeGitCommands { filePath: relativePath, oldPath: oldRelativePath }) - return ( + return assertGitDiffWithinTransportBudget( results[0] ?? { kind: 'text', originalContent: '', modifiedContent: '', originalIsBinary: false, modifiedIsBinary: false - } + }, + maxContentBytes ) } - return getBranchDiff( - target.worktree.path, - { - mergeBase: compare.mergeBase, - headOid: compare.headOid, - filePath: relativePath, - oldPath: oldRelativePath - }, - localGitOptionsForTarget(target) + return assertGitDiffWithinTransportBudget( + await getBranchDiff( + target.worktree.path, + { + mergeBase: compare.mergeBase, + headOid: compare.headOid, + filePath: relativePath, + oldPath: oldRelativePath + }, + localGitOptionsForTarget(target) + ), + maxContentBytes ) } async getRuntimeGitCommitDiff( worktreeSelector: string, - args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } + args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string }, + maxContentBytes?: number ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const relativePath = normalizeRuntimeRelativePath(args.filePath) @@ -572,22 +588,28 @@ export class RuntimeGitCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.getCommitDiff(target.worktree.path, { - commitOid: args.commitOid, - parentOid: args.parentOid, - filePath: relativePath, - oldPath: oldRelativePath - }) + return assertGitDiffWithinTransportBudget( + await provider.getCommitDiff(target.worktree.path, { + commitOid: args.commitOid, + parentOid: args.parentOid, + filePath: relativePath, + oldPath: oldRelativePath + }), + maxContentBytes + ) } - return getCommitDiff( - target.worktree.path, - { - commitOid: args.commitOid, - parentOid: args.parentOid, - filePath: relativePath, - oldPath: oldRelativePath - }, - localGitOptionsForTarget(target) + return assertGitDiffWithinTransportBudget( + await getCommitDiff( + target.worktree.path, + { + commitOid: args.commitOid, + parentOid: args.parentOid, + filePath: relativePath, + oldPath: oldRelativePath + }, + localGitOptionsForTarget(target) + ), + maxContentBytes ) } diff --git a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts index 3d4bd91536a..ca79f9e55a8 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -376,4 +376,28 @@ describe('remote runtime terminal split authority', () => { expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID) }) + + it('preserves the split error when kill and retirement throw', async () => { + const harness = createHarness(false, { + deferReveal: true, + includePairedSnapshot: true, + sourceIncarnationId: 'projected-before', + stopAndWaitResult: false + }) + harness.kill.mockImplementation(() => { + throw new Error('kill failed') + }) + harness.retireRejectedPty.mockImplementation(() => { + throw new Error('retire failed') + }) + + const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) + await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce()) + harness.replaceSourceIncarnation('projected-after') + harness.resolveReveal() + + await expect(split).rejects.toThrow('terminal_split_source_not_found') + expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) + expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID) + }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 49a5a3db094..3ee35d809df 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -63,6 +63,7 @@ import { resolveWorktreeScanCacheTtlMs, type RuntimeTerminalAgentStatusEvent } from './orca-runtime' +import { RUNTIME_GRAPH_RELOAD_TIMEOUT_MS } from './runtime-graph-reload-lifecycle' import { appendRecentPtyPathCandidates, recentTerminalPathCandidatesIncludePath, @@ -1687,6 +1688,7 @@ describe('OrcaRuntimeService', () => { } as never) expect(runtime.getClientSettings()).toMatchObject({ + worktreeVisibilityDefaults: { external: 'hide' }, experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, minimaxGroupId: 'group-42', @@ -1812,6 +1814,23 @@ describe('OrcaRuntimeService', () => { }) }) + it('broadcasts visibility default changes to paired clients', async () => { + let settings = { ...store.getSettings(), worktreeVisibilityDefaults: { external: 'hide' } } + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => settings, + updateSettings: (updates: Partial) => { + settings = { ...settings, ...updates } + } + } as never) + const events: unknown[] = [] + runtime.onClientEvent((event) => events.push(event)) + + await runtime.updateClientSettings({ worktreeVisibilityDefaults: { external: 'show' } }) + + expect(events).toContainEqual({ type: 'reposChanged' }) + }) + it('reconciles hooks only when paired-client hook settings change', async () => { electronMocks.app.isPackaged = true let settings = { @@ -2489,6 +2508,62 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().graphStatus).toBe('ready') }) + it('restores a surviving renderer when its reload is cancelled', () => { + const runtime = createRuntime() + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + const fence = runtime.markRendererReloading(TEST_WINDOW_ID) + if (fence === null) { + throw new Error('expected active renderer reload fence') + } + + expect(fence.recovery).toBe('renderer') + expect(runtime.getStatus().graphStatus).toBe('reloading') + expect(runtime.markRendererReloadCancelled(TEST_WINDOW_ID, fence)).toBe(true) + expect(runtime.getStatus().graphStatus).toBe('ready') + }) + + it('keeps an earlier committed reload fenced when a later reload is cancelled', async () => { + vi.useFakeTimers() + try { + const runtime = createRuntime() + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + const committedFence = runtime.markRendererReloading(TEST_WINDOW_ID) + const cancelledFence = runtime.markRendererReloading(TEST_WINDOW_ID) + if (committedFence === null || cancelledFence === null) { + throw new Error('expected active renderer reload fences') + } + + expect(cancelledFence.recovery).toBe('reloading') + expect(runtime.markRendererReloadCancelled(TEST_WINDOW_ID, committedFence)).toBe(false) + expect(runtime.markRendererReloadCancelled(TEST_WINDOW_ID, cancelledFence)).toBe(false) + await vi.advanceTimersByTimeAsync(RUNTIME_GRAPH_RELOAD_TIMEOUT_MS - 1) + expect(runtime.getStatus().graphStatus).toBe('reloading') + await vi.advanceTimersByTimeAsync(1) + expect(runtime.getStatus().graphStatus).toBe('unavailable') + } finally { + vi.useRealTimers() + } + }) + + it('restores headless authority when desktop promotion navigation is cancelled', () => { + const runtime = createRuntime() + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.attachWindow(TEST_WINDOW_ID) + const fence = runtime.markRendererReloading(TEST_WINDOW_ID) + if (fence === null) { + throw new Error('expected active promotion reload fence') + } + + expect(fence.recovery).toBe('headless') + expect(runtime.markRendererReloadCancelled(TEST_WINDOW_ID, fence)).toBe(false) + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: HEADLESS_RUNTIME_WINDOW_ID, + graphStatus: 'ready' + }) + }) + it('drops back to unavailable and clears authority when the window disappears', () => { const runtime = createRuntime() @@ -2504,6 +2579,164 @@ describe('OrcaRuntimeService', () => { }) }) + it('restores headless graph authority after a promoted renderer reload times out', async () => { + vi.useFakeTimers() + try { + const runtime = createRuntime() + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + runtime.attachWindow(TEST_WINDOW_ID) + + await vi.advanceTimersByTimeAsync(RUNTIME_GRAPH_RELOAD_TIMEOUT_MS) + + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: HEADLESS_RUNTIME_WINDOW_ID, + graphStatus: 'ready' + }) + expect((await runtime.listTerminals()).terminals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ptyId: 'persisted-pty', connected: true, writable: true }) + ]) + ) + } finally { + vi.useRealTimers() + } + }) + + it('moves a desktop graph to unavailable when its reload times out', async () => { + vi.useFakeTimers() + try { + const runtime = createRuntime() + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + runtime.markRendererReloading(TEST_WINDOW_ID) + + await vi.advanceTimersByTimeAsync(RUNTIME_GRAPH_RELOAD_TIMEOUT_MS) + + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: TEST_WINDOW_ID, + graphStatus: 'unavailable', + rendererGraphEpoch: 1 + }) + } finally { + vi.useRealTimers() + } + }) + + it('recovers a failed headless promotion and accepts a later renderer generation', () => { + const runtime = createRuntime() + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.attachWindow(TEST_WINDOW_ID) + + runtime.markGraphReloadFailed(TEST_WINDOW_ID, 'renderer-process-gone') + + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: HEADLESS_RUNTIME_WINDOW_ID, + graphStatus: 'ready' + }) + + runtime.syncWindowGraph(TEST_WINDOW_ID, { tabs: [], leaves: [] }) + + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: TEST_WINDOW_ID, + graphStatus: 'ready' + }) + }) + + it('retires the headless fallback after renderer promotion succeeds', () => { + const runtime = createRuntime() + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.syncWindowGraph(TEST_WINDOW_ID, { tabs: [], leaves: [] }) + + runtime.markRendererReloading(TEST_WINDOW_ID) + runtime.markGraphReloadFailed(TEST_WINDOW_ID, 'renderer-process-gone') + + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: TEST_WINDOW_ID, + graphStatus: 'unavailable' + }) + + runtime.syncWindowGraph(TEST_WINDOW_ID, { tabs: [], leaves: [] }) + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: TEST_WINDOW_ID, + graphStatus: 'ready' + }) + }) + + it('does not let a superseded reload timeout overwrite a newer renderer graph', async () => { + vi.useFakeTimers() + try { + const runtime = createRuntime() + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + runtime.markRendererReloading(TEST_WINDOW_ID) + await vi.advanceTimersByTimeAsync(RUNTIME_GRAPH_RELOAD_TIMEOUT_MS / 2) + runtime.markRendererReloading(TEST_WINDOW_ID) + + await vi.advanceTimersByTimeAsync(RUNTIME_GRAPH_RELOAD_TIMEOUT_MS / 2) + + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: TEST_WINDOW_ID, + graphStatus: 'reloading' + }) + + await vi.advanceTimersByTimeAsync(RUNTIME_GRAPH_RELOAD_TIMEOUT_MS / 2) + + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: TEST_WINDOW_ID, + graphStatus: 'unavailable' + }) + } finally { + vi.useRealTimers() + } + }) + + it('rejects a same-frame graph publication from the superseded renderer generation', () => { + const runtime = createRuntime() + runtime.attachWindow(TEST_WINDOW_ID) + runtime.syncWindowGraph(TEST_WINDOW_ID, { + tabs: [], + leaves: [], + rendererGeneration: 'renderer-a' + }) + runtime.markRendererReloading(TEST_WINDOW_ID) + + expect(() => + runtime.syncWindowGraph(TEST_WINDOW_ID, { + tabs: [], + leaves: [], + rendererGeneration: 'renderer-a' + }) + ).toThrow('Runtime graph publisher belongs to a superseded renderer generation') + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: TEST_WINDOW_ID, + graphStatus: 'reloading' + }) + }) + + it('keeps a restored headless graph pinned to the failed promotion window', () => { + const runtime = createRuntime() + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReloadFailed(TEST_WINDOW_ID, 'renderer-process-gone') + + expect(() => + runtime.syncWindowGraph(2, { + tabs: [], + leaves: [], + rendererGeneration: 'renderer-b' + }) + ).toThrow('Runtime graph publisher does not match the pending desktop promotion') + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: HEADLESS_RUNTIME_WINDOW_ID, + graphStatus: 'ready' + }) + }) + it('stays unavailable during initial loads before a graph is published', () => { const runtime = createRuntime() @@ -3880,7 +4113,10 @@ describe('OrcaRuntimeService', () => { expect(fsProvider.stat).toHaveBeenCalledWith(folderPath) expect(fsProvider.readDir).toHaveBeenCalledWith('/srv/platform/src') expect(fsProvider.stat).toHaveBeenCalledWith('/srv/platform/src/app.ts') - expect(fsProvider.readFile).toHaveBeenCalledWith('/srv/platform/src/app.ts') + expect(fsProvider.readFile).toHaveBeenCalledWith('/srv/platform/src/app.ts', { + maxBinaryBytes: 10 * 1024 * 1024, + maxTextBytes: 512 * 1024 + }) }) it('lists persisted SSH worktrees while the git provider is unavailable', async () => { @@ -8243,11 +8479,10 @@ describe('OrcaRuntimeService', () => { expect(added).toEqual([ expect.objectContaining({ badgeColor: DEFAULT_REPO_BADGE_COLOR, - externalWorktreeVisibility: 'hide', externalWorktreeVisibilityLegacy: false }) ]) - expect(repo.externalWorktreeVisibility).toBe('hide') + expect(repo.externalWorktreeVisibility).toBeUndefined() expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(colorStore, repo) } finally { spawnSpy.mockRestore() @@ -16395,75 +16630,78 @@ describe('OrcaRuntimeService', () => { } }) - it('waits for Claude output to settle after its first render marker before one submit', async () => { - vi.useFakeTimers() - try { - const writes: string[] = [] - let composerReady = false - let prematureEnters = 0 - let submissions = 0 - const runtime = new OrcaRuntimeService(store) - runtime.setPtyController({ - spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), - write: (_ptyId, data) => { - writes.push(data) - if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { - setTimeout(() => { - runtime.onPtyData('pty-bg', 'partial redraw without cursor', Date.now()) - }, 650) - setTimeout(() => { - runtime.onPtyData('pty-bg', '\x1b[?2', Date.now()) - }, 750) - setTimeout(() => { - runtime.onPtyData('pty-bg', '5h intermediate frame', Date.now()) - }, 751) - setTimeout(() => { - runtime.onPtyData('pty-bg', 'continued composer render', Date.now()) - }, 900) - setTimeout(() => { - composerReady = true - runtime.onPtyData('pty-bg', 'final composer frame', Date.now()) - }, 1_000) - } - if (data === '\r') { - if (composerReady) { - submissions += 1 - } else { - prematureEnters += 1 + it.each(['claude', 'codex'] as const)( + 'waits for %s output to settle after its first render marker before one submit', + async (agent) => { + vi.useFakeTimers() + try { + const writes: string[] = [] + let composerReady = false + let prematureEnters = 0 + let submissions = 0 + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + setTimeout(() => { + runtime.onPtyData('pty-bg', 'partial redraw without cursor', Date.now()) + }, 650) + setTimeout(() => { + runtime.onPtyData('pty-bg', '\x1b[?2', Date.now()) + }, 750) + setTimeout(() => { + runtime.onPtyData('pty-bg', '5h intermediate frame', Date.now()) + }, 751) + setTimeout(() => { + runtime.onPtyData('pty-bg', 'continued composer render', Date.now()) + }, 900) + setTimeout(() => { + composerReady = true + runtime.onPtyData('pty-bg', 'final composer frame', Date.now()) + }, 1_000) } - } - return true - }, - kill: () => true, - getForegroundProcess: async () => null - }) - const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { - launchAgent: 'claude' - }) - const assertAuthority = vi.fn() + if (data === '\r') { + if (composerReady) { + submissions += 1 + } else { + prematureEnters += 1 + } + } + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + launchAgent: agent + }) + const assertAuthority = vi.fn() - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { - beforeWrite: assertAuthority - }) - await vi.advanceTimersByTimeAsync(500) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + beforeWrite: assertAuthority + }) + await vi.advanceTimersByTimeAsync(500) - expect(writes).not.toContain('\r') - await vi.advanceTimersByTimeAsync(150) - expect(writes).not.toContain('\r') - await vi.advanceTimersByTimeAsync(101) - expect(writes).not.toContain('\r') - await vi.advanceTimersByTimeAsync(1_748) - expect(writes).not.toContain('\r') - await vi.advanceTimersByTimeAsync(1) - await sendPromise - expect(prematureEnters).toBe(0) - expect(submissions).toBe(1) - expect(writes.filter((data) => data === '\r')).toHaveLength(1) - expect(assertAuthority).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() + expect(writes).not.toContain('\r') + await vi.advanceTimersByTimeAsync(150) + expect(writes).not.toContain('\r') + await vi.advanceTimersByTimeAsync(101) + expect(writes).not.toContain('\r') + await vi.advanceTimersByTimeAsync(1_748) + expect(writes).not.toContain('\r') + await vi.advanceTimersByTimeAsync(1) + await sendPromise + expect(prematureEnters).toBe(0) + expect(submissions).toBe(1) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + expect(assertAuthority).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } } - }) + ) it('submits a silent Claude composer once after the bounded render fallback', async () => { vi.useFakeTimers() @@ -16495,6 +16733,46 @@ describe('OrcaRuntimeService', () => { } }) + it('gives a late Codex render marker a fresh quiescence window', async () => { + vi.useFakeTimers() + try { + const writes: string[] = [] + let composerReady = false + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + setTimeout(() => runtime.onPtyData('pty-bg', '\x1b[?25h', Date.now()), 7_900) + setTimeout(() => { + composerReady = true + runtime.onPtyData('pty-bg', 'final slow composer frame', Date.now()) + }, 8_100) + } + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + launchAgent: 'codex' + }) + + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + await vi.advanceTimersByTimeAsync(8_000) + expect(writes).not.toContain('\r') + await vi.advanceTimersByTimeAsync(1_599) + expect(writes).not.toContain('\r') + await vi.advanceTimersByTimeAsync(1) + await sendPromise + expect(composerReady).toBe(true) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + it('bounds a Claude render that never settles to one fallback submit', async () => { vi.useFakeTimers() try { @@ -16523,7 +16801,7 @@ describe('OrcaRuntimeService', () => { }) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') - await vi.advanceTimersByTimeAsync(7_999) + await vi.advanceTimersByTimeAsync(8_099) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1) @@ -36061,6 +36339,61 @@ describe('OrcaRuntimeService', () => { }) }) + it('applies global custom-source visibility to mobile summaries and file resolution', async () => { + const globalRoot = '/tmp/global-worktrees' + const globalWorktreePath = `${globalRoot}/review` + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: TEST_REPO_PATH, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: globalWorktreePath, + head: 'review', + branch: 'feature/review', + isBare: false, + isMainWorktree: false + } + ]) + const repo = { + ...store.getRepos()[0], + externalWorktreeVisibility: 'hide' as const, + externalWorktreeVisibilityLegacy: false + } + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [repo], + getRepo: () => repo, + getSettings: () => ({ + ...store.getSettings(), + worktreeVisibilityDefaults: { + external: 'hide' as const, + customSources: [{ id: 'global', rootPath: globalRoot }], + sourcePreferences: { custom: { global: 'show' as const } } + } + }) + } as never) + + const summaries = await runtime.getWorktreePs() + const target = await ( + runtime as unknown as { + resolveKnownWorkspaceFileTarget: ( + path: string, + executionHostId: 'local' + ) => Promise<{ worktree: { path: string }; relativePath: string } | null> + } + ).resolveKnownWorkspaceFileTarget(`${globalWorktreePath}/src/app.ts`, 'local') + + expect(summaries.worktrees.map((worktree) => worktree.path)).toContain(globalWorktreePath) + expect(target).toMatchObject({ + worktree: { path: globalWorktreePath }, + relativePath: 'src/app.ts' + }) + }) + it('marks saved session tabs with live PTYs as host sidebar activity', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c2b95928b69..a5e9d451f4b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -424,11 +424,16 @@ import { type RuntimeSessionTabCloseReason, type RuntimeBrowserDriverState, type RuntimeTerminalDriverState, + type RuntimeRendererSyncWindowGraph, type RuntimeSyncWindowGraph, type RuntimeWorktreeListResult, type BrowserTabInfo, type BrowserScreencastResult } from '../../shared/runtime-types' +import { + RUNTIME_GRAPH_RELOAD_TIMEOUT_MS, + RuntimeGraphReloadLifecycle +} from './runtime-graph-reload-lifecycle' import { LINEAR_SEARCH_MAX_LIMIT, LINEAR_WRITE_BODY_CAP, @@ -545,7 +550,7 @@ import { import { isAgentScratchRepoRootPath } from '../../shared/agent-scratch-worktrees' import { createWorktreeVisibilitySourceMatcher, - normalizeCustomWorktreeVisibilitySources, + resolveCustomWorktreeVisibilitySources, type WorktreeVisibilitySourceMatcher } from '../../shared/worktree/visibility-sources' import { @@ -769,6 +774,14 @@ import { type RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerprint' +import { + listStoredWorktreeRowsForRepo, + resolveRepoWorktreeRows, + resolveScopedWorktreeIdRow, + RESOLVED_WORKTREE_REPO_TIMEOUT_MS, + type RepoWorktreeRowDeps +} from './repo-worktree-row-resolution' +import { withTimeout } from '../../shared/promise-timeout-fallback' import { getLocalWorktreePathAccess, removeLocalWorktreePath, @@ -1222,6 +1235,7 @@ type RuntimeStore = { localBaseRefSuggestionDismissed?: boolean branchPrefix: string branchPrefixCustom: string + worktreeVisibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults'] defaultTuiAgent?: GlobalSettings['defaultTuiAgent'] disabledTuiAgents?: GlobalSettings['disabledTuiAgents'] agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] @@ -1894,10 +1908,10 @@ const BRACKETED_PASTE_BEGIN = '\x1b[200~' const BRACKETED_PASTE_END = '\x1b[201~' const BRACKETED_PASTE_QUIET_MS = 1500 const DRAFT_PASTE_READY_TIMEOUT_MS = 8000 -const CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000 -const CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS = 1500 -// Why: Claude emits show-cursor while rendering its composer; output must settle afterward. -const CLAUDE_AGENT_PROMPT_RENDER_MARKER = '\x1b[?25h' +const AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000 +const AGENT_PROMPT_RENDER_QUIET_MS = 1500 +// Why: Claude and Codex emit show-cursor while rendering pasted composer content. +const AGENT_PROMPT_RENDER_MARKER = '\x1b[?25h' const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000 // Why: the split already failed; the caller waits on this teardown only to learn whether the // fallback kill is needed, so keep it short — an unreachable host must not stall the rejection. @@ -2790,6 +2804,11 @@ function getSetupRunnerCommandPlatformForLaunch( return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform) } +export type RuntimeRendererReloadFence = Readonly<{ + revision: number + recovery: 'renderer' | 'headless' | 'reloading' +}> + export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() @@ -2810,6 +2829,18 @@ export class OrcaRuntimeService { private rendererGraphEpoch = 0 private graphStatus: RuntimeGraphStatus = 'unavailable' private authoritativeWindowId: number | null = null + private headlessGraphFallbackAvailable = false + private pendingHeadlessPromotionWindowId: number | null = null + private rendererGeneration: string | null = null + private readonly graphReloadLifecycle = new RuntimeGraphReloadLifecycle({ + timeoutMs: RUNTIME_GRAPH_RELOAD_TIMEOUT_MS, + onSettled: ({ revision, windowId, outcome, durationMs }) => { + console.info( + `[runtime-graph] reload revision=${revision} window=${windowId} outcome=${outcome} durationMs=${durationMs}` + ) + }, + onTimeout: (_revision, windowId) => this.handleGraphReloadTimeout(windowId) + }) // Why: paired graph transactions need foreground timer cadence only until their publication settles. private readonly rendererPublicationThrottle = new RendererPublicationThrottle() private tabs = new Map() @@ -3581,6 +3612,7 @@ export class OrcaRuntimeService { getClientSettings(): Pick< GlobalSettings, + | 'worktreeVisibilityDefaults' | 'defaultTuiAgent' | 'disabledTuiAgents' | 'agentCmdOverrides' @@ -3607,6 +3639,7 @@ export class OrcaRuntimeService { } const settings = this.store.getSettings() return { + worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' }, defaultTuiAgent: settings.defaultTuiAgent ?? null, disabledTuiAgents: settings.disabledTuiAgents ?? [], agentCmdOverrides: settings.agentCmdOverrides ?? {}, @@ -3658,6 +3691,7 @@ export class OrcaRuntimeService { async updateClientSettings( updates: Pick< Partial, + | 'worktreeVisibilityDefaults' | 'agentStatusHooksEnabled' | 'defaultTuiAgent' | 'disabledTuiAgents' @@ -3678,6 +3712,7 @@ export class OrcaRuntimeService { ): Promise< Pick< GlobalSettings, + | 'worktreeVisibilityDefaults' | 'defaultTuiAgent' | 'disabledTuiAgents' | 'agentCmdOverrides' @@ -3704,6 +3739,9 @@ export class OrcaRuntimeService { const before = beforeSettings.agentStatusHooksEnabled !== false this.store.updateSettings(updates, { notifyListeners: true }) const settings = this.store.getSettings() + if (updates.worktreeVisibilityDefaults !== undefined) { + this.notifyReposChanged() + } if ( (typeof updates.agentStatusHooksEnabled === 'boolean' && before !== updates.agentStatusHooksEnabled) || @@ -5577,11 +5615,18 @@ export class OrcaRuntimeService { attachWindow(windowId: number): void { if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) { + if ( + this.pendingHeadlessPromotionWindowId !== null && + windowId !== this.pendingHeadlessPromotionWindowId + ) { + return + } // Why: promotion is a renderer reload of the same graph owner, not a new // runtime; stale handles must transition before the real window publishes. this.persistWindowlessPtyBindingsForDesktopAttach() - this.markRendererReloading(HEADLESS_RUNTIME_WINDOW_ID) + this.pendingHeadlessPromotionWindowId = windowId this.authoritativeWindowId = windowId + this.beginGraphReload(windowId) return } if (this.authoritativeWindowId === null) { @@ -5658,13 +5703,45 @@ export class OrcaRuntimeService { } } - syncWindowGraph(windowId: number, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult { + syncWindowGraph( + windowId: number, + graph: RuntimeSyncWindowGraph | RuntimeRendererSyncWindowGraph + ): RuntimeSyncWindowGraphResult { + if ( + windowId !== HEADLESS_RUNTIME_WINDOW_ID && + this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID && + this.headlessGraphFallbackAvailable + ) { + if (windowId !== this.pendingHeadlessPromotionWindowId) { + throw new Error('Runtime graph publisher does not match the pending desktop promotion') + } + // Why: a renderer may publish after a failed promotion was restored to + // headless authority; accepting that late healthy graph is self-healing. + this.attachWindow(windowId) + } if (this.authoritativeWindowId === null) { this.authoritativeWindowId = windowId } if (windowId !== this.authoritativeWindowId) { throw new Error('Runtime graph publisher does not match the authoritative window') } + const rendererGeneration = + windowId === HEADLESS_RUNTIME_WINDOW_ID + ? null + : 'rendererGeneration' in graph && typeof graph.rendererGeneration === 'string' + ? graph.rendererGeneration + : undefined + if ( + typeof rendererGeneration === 'string' && + rendererGeneration === this.rendererGeneration && + this.graphStatus !== 'ready' + ) { + throw new Error('Runtime graph publisher belongs to a superseded renderer generation') + } + if (windowId === HEADLESS_RUNTIME_WINDOW_ID) { + this.headlessGraphFallbackAvailable = true + this.rendererGeneration = null + } const graphWasReady = this.graphStatus === 'ready' const previousTabs = this.tabs @@ -5832,9 +5909,10 @@ export class OrcaRuntimeService { this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) } } - this.graphStatus = 'ready' - this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) - this.refreshWritableFlags() + this.markGraphReady(windowId) + if (rendererGeneration !== undefined) { + this.rendererGeneration = rendererGeneration + } for (const leaf of this.leaves.values()) { this.adoptPreAllocatedHandle(leaf) const previousLeaf = previousLeaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) @@ -10150,13 +10228,17 @@ export class OrcaRuntimeService { } >() const resolvedWorktrees = await this.listResolvedWorktrees() - const visibilitySourceMatchersByRepoId = - this.buildRuntimeVisibilitySourceMatchersByRepoId(resolvedWorktrees) + const settings = this.store?.getSettings() + const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId( + resolvedWorktrees, + settings?.worktreeVisibilityDefaults + ) for (const worktree of resolvedWorktrees) { if ( !this.isRuntimeWorktreeVisible( worktree, - visibilitySourceMatchersByRepoId.get(worktree.repoId) + visibilitySourceMatchersByRepoId.get(worktree.repoId), + settings ) ) { continue @@ -18403,7 +18485,7 @@ export class OrcaRuntimeService { } = {} ): Promise { const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) - const renderGate = this.createClaudeAgentPromptRenderGate(ptyId) + const renderGate = this.createAgentPromptRenderGate(ptyId) let wrotePasteBytes = false let completedPaste = false try { @@ -18470,13 +18552,14 @@ export class OrcaRuntimeService { } } - private createClaudeAgentPromptRenderGate(ptyId: string): { + private createAgentPromptRenderGate(ptyId: string): { arm: () => void wait: () => Promise dispose: () => void } | null { const pty = this.ptysById.get(ptyId) - if ((pty?.launchAgent ?? pty?.foregroundAgent) !== 'claude') { + const agent = pty?.launchAgent ?? pty?.foregroundAgent + if (agent !== 'claude' && agent !== 'codex') { return null } let armed = false @@ -18509,7 +18592,13 @@ export class OrcaRuntimeService { if (quietTimer) { clearTimeout(quietTimer) } - quietTimer = setTimeout(finish, CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS) + quietTimer = setTimeout(finish, AGENT_PROMPT_RENDER_QUIET_MS) + } + const armHardTimer = (): void => { + if (hardTimer) { + clearTimeout(hardTimer) + } + hardTimer = setTimeout(finish, AGENT_PROMPT_RENDER_TIMEOUT_MS) } const unsubscribe = this.subscribeToTerminalData(ptyId, (data) => { if (!armed || settled) { @@ -18517,11 +18606,13 @@ export class OrcaRuntimeService { } if (!observedMarker) { const combined = markerCarry + data - markerCarry = combined.slice(-(CLAUDE_AGENT_PROMPT_RENDER_MARKER.length - 1)) - if (!combined.includes(CLAUDE_AGENT_PROMPT_RENDER_MARKER)) { + markerCarry = combined.slice(-(AGENT_PROMPT_RENDER_MARKER.length - 1)) + if (!combined.includes(AGENT_PROMPT_RENDER_MARKER)) { return } observedMarker = true + // Why: a slow initial redraw must still receive the full settlement window. + armHardTimer() } armQuietTimer() }) @@ -18534,7 +18625,9 @@ export class OrcaRuntimeService { if (settled) { return } - hardTimer = setTimeout(finish, CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS) + if (!hardTimer) { + armHardTimer() + } await rendered }, dispose: () => { @@ -18820,7 +18913,10 @@ export class OrcaRuntimeService { }) } - async getWorktreePs(limit = DEFAULT_WORKTREE_PS_LIMIT): Promise<{ + async getWorktreePs( + limit = DEFAULT_WORKTREE_PS_LIMIT, + sourceDefaultsSupported = true + ): Promise<{ worktrees: RuntimeWorktreePsSummary[] totalCount: number truncated: boolean @@ -18829,11 +18925,25 @@ export class OrcaRuntimeService { throw new Error('invalid_limit') } const resolvedWorktreeSnapshot = await this.listResolvedWorktreeSnapshot() + const settings = this.store?.getSettings() + const visibilityDefaults = sourceDefaultsSupported + ? settings?.worktreeVisibilityDefaults + : settings?.worktreeVisibilityDefaults + ? { external: settings.worktreeVisibilityDefaults.external } + : undefined + const visibilitySettings = settings + ? { ...settings, worktreeVisibilityDefaults: visibilityDefaults } + : undefined const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId( - resolvedWorktreeSnapshot.worktrees + resolvedWorktreeSnapshot.worktrees, + visibilityDefaults ) const resolvedWorktrees = resolvedWorktreeSnapshot.worktrees.filter((worktree) => - this.isRuntimeWorktreeVisible(worktree, visibilitySourceMatchersByRepoId.get(worktree.repoId)) + this.isRuntimeWorktreeVisible( + worktree, + visibilitySourceMatchersByRepoId.get(worktree.repoId), + visibilitySettings + ) ) // Why: worktree.ps backs the mobile sidebar, so it must use the same // host-owned imported-worktree visibility gate as worktree.list/desktop. @@ -19856,7 +19966,6 @@ export class OrcaRuntimeService { badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: 'git', - externalWorktreeVisibility: 'hide', externalWorktreeVisibilityLegacy: false, ...(group ? { @@ -19993,12 +20102,7 @@ export class OrcaRuntimeService { ...detected, addedAt: Date.now(), kind, - ...(kind === 'git' - ? { - externalWorktreeVisibility: 'hide' as const, - externalWorktreeVisibilityLegacy: false - } - : {}) + ...(kind === 'git' ? { externalWorktreeVisibilityLegacy: false } : {}) } this.store.addRepo(repo) await prepareLocalWorktreeRootForRepo(this.store, repo) @@ -20114,12 +20218,7 @@ export class OrcaRuntimeService { ...detected, addedAt: Date.now(), kind: repoKind, - ...(repoKind === 'git' - ? { - externalWorktreeVisibility: 'hide' as const, - externalWorktreeVisibilityLegacy: false - } - : {}) + ...(repoKind === 'git' ? { externalWorktreeVisibilityLegacy: false } : {}) } this.store.addRepo(repo) await prepareLocalWorktreeRootForRepo(this.store, repo) @@ -20288,7 +20387,6 @@ export class OrcaRuntimeService { ...detected, addedAt: Date.now(), kind: 'git', - externalWorktreeVisibility: 'hide', externalWorktreeVisibilityLegacy: false } this.store.addRepo(repo) @@ -20336,7 +20434,6 @@ export class OrcaRuntimeService { | 'kind' | 'symlinkPaths' | 'issueSourcePreference' - | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' @@ -20347,6 +20444,7 @@ export class OrcaRuntimeService { | 'projectGroupOrder' > > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null } @@ -20359,6 +20457,9 @@ export class OrcaRuntimeService { if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) { sanitizedUpdates.worktreeBasePath = undefined } + if ('externalWorktreeVisibility' in updates && updates.externalWorktreeVisibility === null) { + sanitizedUpdates.externalWorktreeVisibility = undefined + } if ( 'externalWorktreeDiscoverySuppressedAt' in updates && updates.externalWorktreeDiscoverySuppressedAt === null @@ -22136,22 +22237,35 @@ export class OrcaRuntimeService { async listManagedWorktrees( repoSelector?: string, - limit = DEFAULT_WORKTREE_LIST_LIMIT + limit = DEFAULT_WORKTREE_LIST_LIMIT, + sourceDefaultsSupported = true ): Promise { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') } const resolved = await this.listResolvedWorktrees() const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null - const visibilitySourceMatchersByRepoId = - this.buildRuntimeVisibilitySourceMatchersByRepoId(resolved) + const settings = this.store?.getSettings() + const visibilityDefaults = sourceDefaultsSupported + ? settings?.worktreeVisibilityDefaults + : settings?.worktreeVisibilityDefaults + ? { external: settings.worktreeVisibilityDefaults.external } + : undefined + const visibilitySettings = settings + ? { ...settings, worktreeVisibilityDefaults: visibilityDefaults } + : undefined + const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId( + resolved, + visibilityDefaults + ) const worktrees = resolved.filter((worktree) => { if (repoId && worktree.repoId !== repoId) { return false } return this.isRuntimeWorktreeVisible( worktree, - visibilitySourceMatchersByRepoId.get(worktree.repoId) + visibilitySourceMatchersByRepoId.get(worktree.repoId), + visibilitySettings ) }) return { @@ -22163,25 +22277,35 @@ export class OrcaRuntimeService { async listDetectedManagedWorktrees( repoSelector: string, - connectionId?: string | null + connectionId?: string | null, + sourceDefaultsSupported = true ): Promise { return this.listDetectedWorktreesForResolvedRepo( - await this.resolveRepoSelectorForConnection(repoSelector, connectionId) + await this.resolveRepoSelectorForConnection(repoSelector, connectionId), + sourceDefaultsSupported ) } private async listDetectedWorktreesForResolvedRepo( - repo: Repo + repo: Repo, + sourceDefaultsSupported = true ): Promise { const store = this.requireStore() + const settings = store.getSettings() + const visibilityDefaults = sourceDefaultsSupported + ? settings.worktreeVisibilityDefaults + : settings.worktreeVisibilityDefaults + ? { external: settings.worktreeVisibilityDefaults.external } + : undefined + const visibilitySettings = { ...settings, worktreeVisibilityDefaults: visibilityDefaults } if (isFolderRepo(repo)) { const worktrees = listRuntimeFolderWorkspaces(store, repo) const matcher = createWorktreeVisibilitySourceMatcher( [repo.path, ...worktrees.map((worktree) => worktree.path)], - normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [] + resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults) ) const detected = worktrees.map((worktree) => - this.toRuntimeDetectedWorktree(repo, worktree, matcher) + this.toRuntimeDetectedWorktree(repo, worktree, matcher, visibilitySettings) ) return { repoId: repo.id, @@ -22201,7 +22325,7 @@ export class OrcaRuntimeService { } const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( [repo.path, ...scan.worktrees.map((worktree) => worktree.path)], - normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [] + resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults) ) const detected = scan.worktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` @@ -22213,7 +22337,8 @@ export class OrcaRuntimeService { const detectedWorktree = this.toRuntimeDetectedWorktree( repo, worktree, - worktreeVisibilitySourceMatcher + worktreeVisibilitySourceMatcher, + visibilitySettings ) if (scan.ok) { return detectedWorktree @@ -22280,17 +22405,20 @@ export class OrcaRuntimeService { private isRuntimeWorktreeVisible( worktree: Worktree, - worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher + worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher, + settings?: ReturnType ): boolean { const repo = this.store?.getRepo(worktree.repoId) if (!repo || !this.store) { return true } - return this.toRuntimeDetectedWorktree(repo, worktree, worktreeVisibilitySourceMatcher).visible + return this.toRuntimeDetectedWorktree(repo, worktree, worktreeVisibilitySourceMatcher, settings) + .visible } private buildRuntimeVisibilitySourceMatchersByRepoId( - worktrees: readonly Worktree[] + worktrees: readonly Worktree[], + visibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults'] ): Map { const checkoutPathsByRepoId = new Map() for (const worktree of worktrees) { @@ -22305,7 +22433,7 @@ export class OrcaRuntimeService { repo.id, createWorktreeVisibilitySourceMatcher( [repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])], - normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [] + resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults) ) ]) ) @@ -22314,9 +22442,10 @@ export class OrcaRuntimeService { private toRuntimeDetectedWorktree( repo: Repo, worktree: Worktree, - worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher + worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher, + providedSettings?: ReturnType ): DetectedWorktree { - const settings = this.store?.getSettings() + const settings = providedSettings ?? this.store?.getSettings() if (!settings) { return { ...worktree, @@ -28723,9 +28852,17 @@ export class OrcaRuntimeService { // Best-effort fallback below preserves the original split authority error. } if (!stopped) { - this.ptyController.kill(result.id) + try { + this.ptyController.kill(result.id) + } catch { + // Best-effort cleanup; retirement below still runs and the original error still throws. + } + } + try { + this.ptyController.retireRejectedPty?.(result.id) + } catch { + // Best-effort cleanup; preserve the original split authority error. } - this.ptyController.retireRejectedPty?.(result.id) throw error } const committedSourceAuthority = sourceAuthority.persisted @@ -29550,16 +29687,38 @@ export class OrcaRuntimeService { return false } - markRendererReloading(windowId: number): void { + markRendererReloading(windowId: number): RuntimeRendererReloadFence | null { + if ( + windowId !== HEADLESS_RUNTIME_WINDOW_ID && + this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID && + this.headlessGraphFallbackAvailable + ) { + this.attachWindow(windowId) + const revision = this.graphReloadLifecycle.getActiveRevision() + return this.authoritativeWindowId === windowId && revision !== null + ? { revision, recovery: 'headless' } + : null + } if (windowId !== this.authoritativeWindowId) { - return + return null + } + if (this.graphStatus === 'reloading') { + return { + revision: this.graphReloadLifecycle.begin(windowId), + recovery: this.shouldRestoreHeadlessGraph(windowId) ? 'headless' : 'reloading' + } } if (this.graphStatus !== 'ready') { - return + return null } + return { revision: this.beginGraphReload(windowId), recovery: 'renderer' } + } + + private beginGraphReload(windowId: number): number { // Why: a renderer reload tears down the live graph, so live handles must go stale immediately, not be reused against the rebuild. this.rendererGraphEpoch += 1 this.graphStatus = 'reloading' + const revision = this.graphReloadLifecycle.begin(windowId) this.setTerminalSideEffectConsumerAvailable(false) this.rememberDetachedPreAllocatedLeaves() this.handles.clear() @@ -29567,21 +29726,76 @@ export class OrcaRuntimeService { // Why: handleByPtyId (pre-allocated CLI handles) survives reloads so CLI agents keep control; adoptPreAllocatedHandle re-links on the new graph. this.rejectAllWaiters('terminal_handle_stale') this.refreshWritableFlags() + return revision + } + + markRendererReloadCancelled(windowId: number, fence: RuntimeRendererReloadFence): boolean { + if ( + windowId !== this.authoritativeWindowId || + this.graphStatus !== 'reloading' || + !this.graphReloadLifecycle.settle(fence.revision, 'cancelled') + ) { + return false + } + if (fence.recovery === 'headless' && this.shouldRestoreHeadlessGraph(windowId)) { + this.restoreHeadlessGraphAuthority() + return false + } + if (fence.recovery === 'renderer') { + this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(true) + this.refreshWritableFlags() + return true + } + this.graphReloadLifecycle.begin(windowId) + return false } markGraphReady(windowId: number): void { if (windowId !== this.authoritativeWindowId) { return } + this.graphReloadLifecycle.settleActive('success') + if (windowId !== HEADLESS_RUNTIME_WINDOW_ID) { + this.headlessGraphFallbackAvailable = false + this.pendingHeadlessPromotionWindowId = null + } this.graphStatus = 'ready' this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() } - markGraphUnavailable(windowId: number): void { + markGraphReloadFailed( + windowId: number, + _reason: 'renderer-frame-unavailable' | 'renderer-process-gone' + ): void { if (windowId !== this.authoritativeWindowId) { return } + if (this.graphStatus === 'ready') { + this.beginGraphReload(windowId) + } + this.graphReloadLifecycle.settleActive('failure') + this.transitionGraphReloadToTerminalState(windowId) + } + + markGraphUnavailable(windowId: number): void { + if ( + this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID && + windowId === this.pendingHeadlessPromotionWindowId + ) { + this.pendingHeadlessPromotionWindowId = null + return + } + if (windowId !== this.authoritativeWindowId) { + return + } + this.graphReloadLifecycle.settleActive('cancelled') + if (this.shouldRestoreHeadlessGraph(windowId)) { + this.pendingHeadlessPromotionWindowId = null + this.restoreHeadlessGraphAuthority() + return + } // Why: once the authoritative renderer graph disappears, fail closed for live-terminal ops instead of guessing from old state. if (this.graphStatus !== 'unavailable') { this.rendererGraphEpoch += 1 @@ -29599,6 +29813,49 @@ export class OrcaRuntimeService { this.rejectAllWaiters('terminal_handle_stale') } + private handleGraphReloadTimeout(windowId: number): void { + if (windowId !== this.authoritativeWindowId || this.graphStatus !== 'reloading') { + return + } + this.transitionGraphReloadToTerminalState(windowId) + } + + private transitionGraphReloadToTerminalState(windowId: number): void { + if (this.shouldRestoreHeadlessGraph(windowId)) { + this.restoreHeadlessGraphAuthority() + return + } + this.graphStatus = 'unavailable' + this.setTerminalSideEffectConsumerAvailable(false) + this.rememberDetachedPreAllocatedLeaves() + this.tabs.clear() + this.leaves.clear() + this.leavesByPtyId.clear() + this.handles.clear() + this.handleByLeafKey.clear() + this.rejectAllWaiters('terminal_handle_stale') + this.refreshWritableFlags() + } + + private shouldRestoreHeadlessGraph(windowId: number): boolean { + return windowId !== HEADLESS_RUNTIME_WINDOW_ID && this.headlessGraphFallbackAvailable + } + + private restoreHeadlessGraphAuthority(): void { + this.rendererGraphEpoch += 1 + this.authoritativeWindowId = HEADLESS_RUNTIME_WINDOW_ID + this.graphStatus = 'ready' + this.rendererGeneration = null + this.setTerminalSideEffectConsumerAvailable(false) + this.tabs.clear() + this.leaves.clear() + this.leavesByPtyId.clear() + this.handles.clear() + this.handleByLeafKey.clear() + this.rejectAllWaiters('terminal_handle_stale') + this.refreshWritableFlags() + } + private assertGraphReady(): void { if (this.graphStatus !== 'ready') { throw new Error('runtime_unavailable') @@ -29808,6 +30065,15 @@ export class OrcaRuntimeService { private async resolveWorktreeSelector(selector: string): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) + // Why only `id:`: every other selector kind is matched across the whole fleet, and their + // `selector_ambiguous` contract is defined over all repos. Scoping those would silently pick a + // winner where today they correctly refuse. An `id:` selector already names its repo. + if (explicitWorktreeId && !this.hasFreshResolvedWorktreeCache()) { + const scoped = await this.resolveExplicitWorktreeIdScoped(explicitWorktreeId) + if (scoped) { + return scoped + } + } const worktrees = await this.listResolvedWorktrees() let candidates: ResolvedWorktree[] @@ -30395,6 +30661,11 @@ export class OrcaRuntimeService { return resolved } + /** A warm fleet snapshot already answers any selector for free, so scoped scanning must yield to it. */ + private hasFreshResolvedWorktreeCache(): boolean { + return Boolean(this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > Date.now()) + } + private async listResolvedWorktrees(): Promise { return (await this.listResolvedWorktreeSnapshot()).worktrees } @@ -30436,71 +30707,11 @@ export class OrcaRuntimeService { getAgentLaunchPlatformForRepo(repo, projectRuntimeByRepoId.get(repo.id)) ]) ) + const deps = this.repoWorktreeRowDeps() const perRepoWorktrees = await Promise.all( - repos.map(async (repo) => { - if (isFolderRepo(repo)) { - return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({ - ...worktree, - hostId: worktree.hostId ?? getRepoExecutionHostId(repo), - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - git: { - path: worktree.path, - head: worktree.head, - branch: worktree.branch, - isBare: worktree.isBare, - isMainWorktree: worktree.isMainWorktree - }, - displayName: worktree.displayName, - comment: worktree.comment - })) - } - // Why: mobile startup shares this path, so a slow repo scan degrades one repo's metadata instead of blocking all session loading. - // Why the catch: `withTimeout` resolves its fallback on rejection too, so the rejection must be absorbed first for `null` to mean - // "timed out" only. A stall never reached a verdict, so restore persisted rows instead of publishing a healthy-looking empty catalog; - // a rejection is a real answer and keeps its shipped zero-row semantics. - const scan: RuntimeWorktreeScanResult = - (await withTimeout( - this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId).catch( - () => ({ ok: false, worktrees: [] }) satisfies RuntimeWorktreeScanResult - ), - RESOLVED_WORKTREE_REPO_TIMEOUT_MS, - null - )) ?? { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } - const gitWorktrees = scan.worktrees - if (scan.ok) { - pruneLineageForMissingRepoWorktrees(this.requireStore(), repo, gitWorktrees) - } - return gitWorktrees.map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - // Why: lineage validation needs a durable instance ID even when the runtime sees a workspace before renderer discovery-stamp. - const existingMeta = metaById[worktreeId] - const meta = - existingMeta && existingMeta.instanceId - ? existingMeta - : this.store?.setWorktreeMeta(worktreeId, {}) - const merged = { - ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), - hostId: existingMeta?.hostId ?? meta?.hostId ?? getRepoExecutionHostId(repo) - } - return { - ...merged, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - git: { - path: gitWorktree.path, - head: gitWorktree.head, - branch: gitWorktree.branch, - isBare: gitWorktree.isBare, - isMainWorktree: gitWorktree.isMainWorktree - }, - displayName: merged.displayName, - comment: merged.comment - } - }) - }) + repos.map( + async (repo) => await resolveRepoWorktreeRows(deps, repo, metaById, projectRuntimeByRepoId) + ) ) const worktrees = projectResolvedWorktreeLineage( perRepoWorktrees.flat(), @@ -30519,6 +30730,26 @@ export class OrcaRuntimeService { return { worktrees, platformByRepoId } } + /** Bind the runtime-owned scan cache and folder-workspace stamping into the row resolver. */ + private repoWorktreeRowDeps(): RepoWorktreeRowDeps { + const store = this.requireStore() + return { + store, + scanRepo: (repo, projectRuntimeByRepoId) => + this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId), + listFolderWorkspaces: (repo) => listRuntimeFolderWorkspaces(store, repo) + } + } + + private async resolveExplicitWorktreeIdScoped( + worktreeId: string + ): Promise { + if (!this.store) { + return null + } + return await resolveScopedWorktreeIdRow(this.repoWorktreeRowDeps(), worktreeId) + } + private async listRepoWorktreesForResolution( repo: Repo, projectRuntimeByRepoId?: ReadonlyMap @@ -30676,37 +30907,7 @@ export class OrcaRuntimeService { } private listStoredWorktreesForResolution(repo: Repo): GitWorktreeInfo[] { - const store = this.store - if (!store) { - return [] - } - const expectedHostId = getRepoExecutionHostId(repo) - const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length - const byWorktreeId = new Map() - for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) { - const parsed = splitWorktreeId(worktreeId) - if (!parsed || parsed.repoId !== repo.id) { - continue - } - // Why: one repo id can be registered on several execution hosts, so a degraded host must not republish another host's rows (same gate as worktrees.ts). - if (meta.hostId ? meta.hostId !== expectedHostId : repoOwnerCount > 1) { - continue - } - // Why: keep persisted rows for any repo kind while its scan is unreachable or stalled, instead of zero rows (worktrees:list does the same for disconnected SSH). - byWorktreeId.set(worktreeId, { - path: parsed.worktreePath, - head: '', - branch: '', - isBare: false, - isMainWorktree: areWorktreePathsEqual(parsed.worktreePath, repo.path), - ...(meta.sparseDirectories !== undefined || - meta.sparseBaseRef !== undefined || - meta.sparsePresetId !== undefined - ? { isSparse: true } - : {}) - }) - } - return [...byWorktreeId.values()] + return this.store ? listStoredWorktreeRowsForRepo(this.requireStore(), repo) : [] } private async getResolvedWorktreeMap(): Promise> { @@ -37095,7 +37296,6 @@ const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000 // edits are invisible to it and a tip living in packed-refs or reftable only gets an mtime + size // stamp, so a real scan still runs on this interval even while the probe reports "unchanged". export const WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS = 5 * 60_000 -export const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000 // Why reserved rather than spent on the probe: when the probe expires the caller still has to run // `git worktree list` and answer inside the same budget, so the fallback needs its own room. Sized // for a healthy Git on a busy host, well above the tens of milliseconds a warm list costs. @@ -37194,21 +37394,6 @@ function getExplicitWorktreeIdSelector(selector: string | undefined): string | n return id.length > 0 ? id : null } -function withTimeout(promise: Promise, timeoutMs: number, fallback: T): Promise { - let timeout: ReturnType | null = null - return new Promise((resolve) => { - timeout = setTimeout(() => resolve(fallback), timeoutMs) - promise.then( - (value) => resolve(value), - () => resolve(fallback) - ) - }).finally(() => { - if (timeout) { - clearTimeout(timeout) - } - }) -} - function withTimeoutResult( promise: Promise, timeoutMs: number diff --git a/src/main/runtime/repo-worktree-row-resolution.ts b/src/main/runtime/repo-worktree-row-resolution.ts new file mode 100644 index 00000000000..24ca1c36293 --- /dev/null +++ b/src/main/runtime/repo-worktree-row-resolution.ts @@ -0,0 +1,182 @@ +import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import { isFolderRepo } from '../../shared/repo-kind' +import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' +import { withTimeout } from '../../shared/promise-timeout-fallback' +import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' +import type { WorktreeLineage } from '../../shared/worktree/lineage-types' +import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' +import type { Store } from '../persistence' +import { areWorktreePathsEqual, mergeWorktree } from '../ipc/worktree-logic' +import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' +import { resolveLocalProjectRuntimesForRepos } from '../project-runtime-git-options' +import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' + +/** + * Per-repo budget for one resolution pass. Why: mobile startup shares this path, so one slow repo + * degrades its own metadata instead of blocking all session loading. + * + * Exported because the Git-admin fingerprint probe derives its own timeout by subtracting a fallback + * allowance from this, and that invariant only holds with a single source of truth. + */ +export const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000 + +/** + * One repo's resolved row. Builders emit it with the lineage fields empty; `projectResolvedWorktreeLineage` + * fills them in. `lineage` is nullable rather than `null` so a projected row still types honestly — + * intersecting a `null` literal would collapse the projected type back to "never has lineage". + */ +export type RepoWorktreeRow = Worktree & { + parentWorktreeId: string | null + childWorktreeIds: string[] + lineage: WorktreeLineage | null + git: Pick +} + +export type RepoWorktreeRowDeps = { + store: Store + /** The cache-aware per-repo scan. Injected so this module never owns scan-cache state. */ + scanRepo: ( + repo: Repo, + projectRuntimeByRepoId: ReadonlyMap + ) => Promise + /** Folder workspaces are stamped from runtime-owned identity helpers, so the caller supplies them. */ + listFolderWorkspaces: (repo: Repo) => Worktree[] +} + +/** + * Persisted rows for a repo whose scan is unreachable or stalled, so a degraded host publishes what + * it last knew instead of an empty catalog. `worktrees:list` does the same for disconnected SSH. + */ +export function listStoredWorktreeRowsForRepo(store: Store, repo: Repo): GitWorktreeInfo[] { + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length + const byWorktreeId = new Map() + for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) { + const parsed = splitWorktreeId(worktreeId) + if (!parsed || parsed.repoId !== repo.id) { + continue + } + // Why: one repo id can be registered on several execution hosts, so a degraded host must not republish another host's rows (same gate as worktrees.ts). + if (meta.hostId ? meta.hostId !== expectedHostId : repoOwnerCount > 1) { + continue + } + byWorktreeId.set(worktreeId, { + path: parsed.worktreePath, + head: '', + branch: '', + isBare: false, + isMainWorktree: areWorktreePathsEqual(parsed.worktreePath, repo.path), + ...(meta.sparseDirectories !== undefined || + meta.sparseBaseRef !== undefined || + meta.sparsePresetId !== undefined + ? { isSparse: true } + : {}) + }) + } + return [...byWorktreeId.values()] +} + +/** One repo's worktree rows before lineage projection. Shared by the fleet scan and scoped lookups. */ +export async function resolveRepoWorktreeRows( + deps: RepoWorktreeRowDeps, + repo: Repo, + metaById: Record, + projectRuntimeByRepoId: ReadonlyMap +): Promise { + const { store } = deps + if (isFolderRepo(repo)) { + return deps.listFolderWorkspaces(repo).map((worktree) => ({ + ...worktree, + hostId: worktree.hostId ?? getRepoExecutionHostId(repo), + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { + path: worktree.path, + head: worktree.head, + branch: worktree.branch, + isBare: worktree.isBare, + isMainWorktree: worktree.isMainWorktree + }, + displayName: worktree.displayName, + comment: worktree.comment + })) + } + // Why the catch: `withTimeout` resolves its fallback on rejection too, so the rejection must be absorbed + // first for `null` to mean "timed out" only. A stall never reached a verdict, so restore persisted rows + // instead of publishing a healthy-looking empty catalog; a rejection is a real answer and keeps its + // shipped zero-row semantics. + const scan: RuntimeWorktreeScanResult = (await withTimeout( + deps + .scanRepo(repo, projectRuntimeByRepoId) + .catch(() => ({ ok: false, worktrees: [] }) satisfies RuntimeWorktreeScanResult), + RESOLVED_WORKTREE_REPO_TIMEOUT_MS, + null + )) ?? { ok: false, worktrees: listStoredWorktreeRowsForRepo(store, repo) } + const gitWorktrees = scan.worktrees + if (scan.ok) { + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + } + return gitWorktrees.map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + // Why: lineage validation needs a durable instance ID even when the runtime sees a workspace before renderer discovery-stamp. + const existingMeta = metaById[worktreeId] + const meta = + existingMeta && existingMeta.instanceId ? existingMeta : store.setWorktreeMeta(worktreeId, {}) + const merged = { + ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), + hostId: existingMeta?.hostId ?? meta?.hostId ?? getRepoExecutionHostId(repo) + } + return { + ...merged, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { + path: gitWorktree.path, + head: gitWorktree.head, + branch: gitWorktree.branch, + isBare: gitWorktree.isBare, + isMainWorktree: gitWorktree.isMainWorktree + }, + displayName: merged.displayName, + comment: merged.comment + } + }) +} + +/** + * Resolve one `::` worktree id by scanning only its owning repo. + * + * Lineage edges are intra-repo by construction (`sharesResolvedWorktreeLineageBoundary` requires a + * matching repoId), so projecting over one repo's rows yields the same parent and child ids the + * fleet scan would. Returns `null` whenever that does not hold, and the caller falls back. + */ +export async function resolveScopedWorktreeIdRow( + deps: RepoWorktreeRowDeps, + worktreeId: string +): Promise { + const { store } = deps + const parsed = splitWorktreeIdForFilesystem(worktreeId) + if (!parsed?.repoId || !parsed.worktreePath) { + return null + } + const owners = store.getRepos().filter((repo) => repo.id === parsed.repoId) + // Why: one repo id can be registered on several execution hosts, and only the fleet scan decides + // between their rows. Hand those back to the unscoped path rather than guessing. + if (owners.length !== 1) { + return null + } + const repo = owners[0] + const rows = await resolveRepoWorktreeRows( + deps, + repo, + store.getAllWorktreeMeta() ?? {}, + resolveLocalProjectRuntimesForRepos(store, [repo]) + ) + const projected = projectResolvedWorktreeLineage(rows, store.getAllWorktreeLineage?.() ?? {}) + return projected.find((worktree) => worktree.id === worktreeId) ?? null +} diff --git a/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts b/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts index 4e4b5f52ecb..5761ac504aa 100644 --- a/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts +++ b/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts @@ -3,6 +3,11 @@ import type { WebSocket } from 'ws' import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel' import { deriveSharedKey, decrypt, encrypt, generateKeyPair } from './e2ee-crypto' import { createMobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' + +const trackMock = vi.hoisted(() => vi.fn()) + +vi.mock('../../telemetry/client', () => ({ track: trackMock })) // Repro for gap (a): the streaming JSON reply path (encryptedReply) had no // bufferedAmount gate, so a fast producer over a slow link (legacy @@ -64,6 +69,7 @@ function emitReply(ctx: ReturnType, payload: string): void { describe('E2EE text reply backpressure', () => { beforeEach(() => { vi.useFakeTimers() + trackMock.mockReset() }) afterEach(() => { vi.useRealTimers() @@ -100,6 +106,19 @@ describe('E2EE text reply backpressure', () => { expect(decrypt(ctx.ws.sent[baseline]!, ctx.sharedKey)).toBe('{"ok":true}') }) + it('still closes an oversized reply when telemetry throws', () => { + const ctx = setup() + trackMock.mockImplementationOnce(() => { + throw new Error('telemetry unavailable') + }) + + expect(() => + emitReply(ctx, 'x'.repeat(REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + 1)) + ).not.toThrow() + expect(trackMock).toHaveBeenCalledWith('remote_outbound_budget_close', { emitter: 'size' }) + expect(ctx.onError).toHaveBeenCalledWith(1013, 'Outbound reply buffer overflow') + }) + it('rejects aggregate queue growth across independently backpressured sockets', () => { const outboundMemoryBudget = createMobileE2EEOutboundMemoryBudget({ maxBufferedBytes: 1_000, @@ -116,6 +135,10 @@ describe('E2EE text reply backpressure', () => { expect(first.onError).not.toHaveBeenCalled() expect(second.onError).toHaveBeenCalledWith(1013, 'Outbound reply buffer overflow') + // Why: this close kills the whole remote session, so it has to be countable. + expect(trackMock).toHaveBeenCalledWith('remote_outbound_budget_close', { + emitter: 'queue' + }) first.channel.destroy() expect(outboundMemoryBudget.evidence().queuedBytes).toBe(0) }) diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index 04ece28eaf0..d1689c5d7fc 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -19,6 +19,7 @@ import { MobileE2EEDesktopOutboundOwner } from './mobile-e2ee-desktop-outbound-o import { parseRuntimeClientCapabilities } from './runtime-client-capabilities' import type { RuntimeCapability } from '../../../shared/protocol-version' import { parseMobileE2EEV2ClientCapabilities } from './mobile-e2ee-v2-client-capabilities' +import { trackRemoteOutboundBudgetClose } from './remote-outbound-budget-close' const HANDSHAKE_TIMEOUT_MS = 10_000 const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 @@ -148,13 +149,13 @@ export class E2EEChannel { return } if (!isMobileE2EETextPayloadWithinLimit(response)) { - this.onError(1013, 'Outbound reply buffer overflow') + this.closeForOutboundBudget('size') return } this.outbound.enqueueLegacyText( encrypt(response, this.sharedKey), () => Boolean(this.sharedKey), - () => this.onError(1013, 'Outbound reply buffer overflow') + () => this.closeForOutboundBudget('queue') ) } const encryptedBinaryReply = (response: Uint8Array): boolean => { @@ -162,7 +163,7 @@ export class E2EEChannel { return false } if (!isMobileE2EEBinaryPayloadWithinLimit(response)) { - this.onError(1013, 'Outbound reply buffer overflow') + this.closeForOutboundBudget('size') return false } if (!this.outbound.canSend(response.byteLength + 40)) { @@ -307,12 +308,17 @@ export class E2EEChannel { return false } if (!isMobileE2EEOutboundItemWithinLimit(item)) { - this.onError(1013, 'Outbound reply buffer overflow') + this.closeForOutboundBudget('size') return false } - return this.outbound.enqueueV2(item, this.v2Session, () => - this.onError(1013, 'Outbound reply buffer overflow') - ) + return this.outbound.enqueueV2(item, this.v2Session, () => this.closeForOutboundBudget('queue')) + } + + // Why: this close kills the whole remote session. `size` means a producer emitted something + // too big and should fall to zero once producers cap themselves; `queue` means a backed-up link. + private closeForOutboundBudget(emitter: 'size' | 'queue'): void { + trackRemoteOutboundBudgetClose(emitter) + this.onError(1013, 'Outbound reply buffer overflow') } private sendEncryptedControl(message: unknown): void { @@ -320,9 +326,7 @@ export class E2EEChannel { this.enqueueV2({ kind: 'text', plaintext: JSON.stringify(message) }) } else if (this.ws.readyState === this.ws.OPEN && this.sharedKey) { const frame = encrypt(JSON.stringify(message), this.sharedKey) - this.outbound.sendLegacyFrame(frame, () => - this.onError(1013, 'Outbound reply buffer overflow') - ) + this.outbound.sendLegacyFrame(frame, () => this.closeForOutboundBudget('queue')) } } diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index ad5e5f36695..af6fcb6a7b4 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -8,6 +8,7 @@ import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types' import { LINEAR_ERROR_CODES } from '../../../shared/linear/agent-access' import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../shared/agent-session-host-authority' import { ARTIFACT_SHARING_DISABLED_CODE } from '../../../shared/artifact-sharing-gate' +import { GIT_DIFF_TOO_LARGE_CODE } from '../../../shared/git-diff-transport-budget' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -98,6 +99,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'stale_delivery', 'waiter_exists', 'invalid_argument', + GIT_DIFF_TOO_LARGE_CODE, ARTIFACT_SHARING_DISABLED_CODE ]) diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 2e2555038ed..db682332576 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -21,7 +21,9 @@ import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest' import type { TaskProvider } from '../../../../shared/task-providers' import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields' import { TaskResumeState } from './task-resume-state-schema' +import { WorkspaceCleanup } from './workspace-cleanup-ui-schema' import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance' +import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-schema' const NullableString = z.string().nullable() const StringArray = z.array(z.string()) @@ -77,19 +79,6 @@ const WorkspaceStatusDefinition = z.object({ color: z.string().optional(), icon: z.string().optional() }) -const WorkspaceCleanupDismissal = z - .object({ - worktreeId: z.string(), - dismissedAt: z.number().finite(), - fingerprint: z.string(), - classifierVersion: z.number().finite() - }) - .strict() -const WorkspaceCleanup = z - .object({ - dismissals: z.record(z.string(), WorkspaceCleanupDismissal) - }) - .strict() const FeatureInteractionRecord = z .object({ firstInteractedAt: z.number().finite().nonnegative(), @@ -138,6 +127,7 @@ const GitHubProjectSettings = z export const SettingsUpdate = z .object({ + worktreeVisibilityDefaults: WorktreeVisibilityDefaultsUpdate.optional(), defaultTuiAgent: z .unknown() .transform((value) => diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index dc9afe1caaf..a1f24c28a9f 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -21,6 +21,7 @@ function makeRequest(method: string, params?: unknown): RpcRequest { describe('client UI RPC methods', () => { it('returns the runtime host agent settings needed by mobile create flows', async () => { const settings = { + worktreeVisibilityDefaults: { external: 'show' as const }, defaultTuiAgent: 'codex', disabledTuiAgents: ['claude'], agentCmdOverrides: { codex: 'codex --profile work' }, @@ -92,6 +93,17 @@ describe('client UI RPC methods', () => { const response = await dispatcher.dispatch( makeRequest('settings.update', { + worktreeVisibilityDefaults: { + external: 'show', + customSources: [ + { id: 'team', rootPath: ' /srv/team ' }, + { id: 'invalid', rootPath: '../relative' } + ], + sourcePreferences: { + builtIn: { claude: 'show', unknown: 'show' }, + custom: { team: 'hide', 'bad id': 'show' } + } + }, defaultTuiAgent: 'codex', disabledTuiAgents: ['claude', 'not-real', 'claude'], defaultTaskSource: 'linear', @@ -108,6 +120,14 @@ describe('client UI RPC methods', () => { ) expect(runtime.updateClientSettings).toHaveBeenCalledWith({ + worktreeVisibilityDefaults: { + external: 'show', + customSources: [{ id: 'team', rootPath: '/srv/team' }], + sourcePreferences: { + builtIn: { claude: 'show' }, + custom: { team: 'hide' } + } + }, defaultTuiAgent: 'codex', disabledTuiAgents: ['claude'], defaultTaskSource: 'linear', diff --git a/src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts b/src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts new file mode 100644 index 00000000000..c36a34983a9 --- /dev/null +++ b/src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { remoteRpcContentBudget } from '../../../../shared/remote-rpc-content-budget' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { FILE_METHODS } from './files' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('file preview RPC transport budgets', () => { + it.each(['mobile', 'runtime'] as const)( + 'charges the %s request id to both preview content budgets', + async (clientKind) => { + const preview = { + content: 'base64', + isBinary: true, + isImage: true, + mimeType: 'image/png' + } + const readFileExplorerPreview = vi.fn().mockResolvedValue(preview) + const readTerminalArtifactPreview = vi.fn().mockResolvedValue(preview) + const runtime = { + getRuntimeId: () => 'test-runtime', + readFileExplorerPreview, + readTerminalArtifactPreview + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const id = '\u0001'.repeat(8_192) + const reply = vi.fn() + + await dispatcher.dispatchStreaming( + { + ...makeRequest('files.readPreview', { worktree: 'id:wt-1', relativePath: 'logo.png' }), + id + }, + reply, + { clientKind } + ) + await dispatcher.dispatchStreaming( + { + ...makeRequest('files.readTerminalArtifactPreview', { + worktree: 'id:wt-1', + absolutePath: '/tmp/logo.png', + grantId: 'grant-1' + }), + id + }, + reply, + { clientKind } + ) + + const budget = remoteRpcContentBudget(id) + expect(readFileExplorerPreview).toHaveBeenCalledWith('id:wt-1', 'logo.png', budget) + expect(readTerminalArtifactPreview).toHaveBeenCalledWith( + 'id:wt-1', + 'grant-1', + '/tmp/logo.png', + undefined, + budget + ) + } + ) +}) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 741fc65635f..486a63feb9e 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -2,10 +2,18 @@ import { z } from 'zod' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { runFileWatchStream } from './file-watch-stream-lifecycle' +import { remoteRpcContentBudget } from '../../../../shared/remote-rpc-content-budget' let filesWatchSubscriptionSeq = 0 const RUNTIME_FILE_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ +function remoteFileContentBudget( + clientKind: 'mobile' | 'runtime' | undefined, + requestId: string | undefined +): number | undefined { + return clientKind && requestId ? remoteRpcContentBudget(requestId) : undefined +} + function isValidRuntimeFileBase64(value: unknown): value is string { return ( typeof value === 'string' && value.length % 4 !== 1 && RUNTIME_FILE_BASE64_PATTERN.test(value) @@ -281,13 +289,23 @@ export const FILE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'files.readTerminalArtifactPreview', params: TerminalArtifactFile, - handler: async (params, { runtime, clientId }) => - runtime.readTerminalArtifactPreview( - params.worktree, - params.grantId, - params.absolutePath, - clientId - ) + handler: async (params, { runtime, clientId, clientKind, requestId }) => { + const budget = remoteFileContentBudget(clientKind, requestId) + return budget === undefined + ? runtime.readTerminalArtifactPreview( + params.worktree, + params.grantId, + params.absolutePath, + clientId + ) + : runtime.readTerminalArtifactPreview( + params.worktree, + params.grantId, + params.absolutePath, + clientId, + budget + ) + } }), defineMethod({ name: 'files.writeTerminalArtifact', @@ -304,8 +322,12 @@ export const FILE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'files.readPreview', params: FileOpen, - handler: async (params, { runtime }) => - runtime.readFileExplorerPreview(params.worktree, params.relativePath) + handler: async (params, { runtime, clientKind, requestId }) => { + const budget = remoteFileContentBudget(clientKind, requestId) + return budget === undefined + ? runtime.readFileExplorerPreview(params.worktree, params.relativePath) + : runtime.readFileExplorerPreview(params.worktree, params.relativePath, budget) + } }), defineMethod({ name: 'files.readChunk', diff --git a/src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts b/src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts new file mode 100644 index 00000000000..a4b22aaa891 --- /dev/null +++ b/src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts @@ -0,0 +1,176 @@ +// Why: git.diff, git.branchDiff and git.commitDiff all return a GitDiffResult, so capping only the +// first would leave the other two able to kill a remote socket. +import { describe, expect, it, vi } from 'vitest' +import { + REMOTE_RPC_MAX_CONTENT_BYTES, + remoteRpcContentBudget +} from '../../../../shared/remote-rpc-content-budget' +import { assertGitDiffWithinTransportBudget } from '../../../../shared/git-diff-transport-budget' +import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from '../../orca-runtime-git' +import type { RpcRequest, RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { GIT_METHODS } from './git' + +const sshMocks = vi.hoisted(() => ({ getSshGitProvider: vi.fn() })) + +vi.mock('../../../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: sshMocks.getSshGitProvider +})) + +const OVERSIZED_BASE64 = 'A'.repeat(REMOTE_RPC_MAX_CONTENT_BYTES + 1024) + +const OVERSIZED_DIFF: GitDiffResult = { + kind: 'binary', + originalContent: '', + modifiedContent: OVERSIZED_BASE64, + isImage: true, + mimeType: 'image/png', + originalIsBinary: false, + modifiedIsBinary: true +} + +const CASES: readonly { method: string; runtimeMethod: string; params: Record }[] = + [ + { + method: 'git.diff', + runtimeMethod: 'getRuntimeGitDiff', + params: { worktree: 'id:wt-1', filePath: 'assets/logo.png', staged: false } + }, + { + method: 'git.branchDiff', + runtimeMethod: 'getRuntimeGitBranchDiff', + params: { + worktree: 'id:wt-1', + compare: { mergeBase: 'a'.repeat(40), headOid: 'b'.repeat(40) }, + filePath: 'assets/logo.png' + } + }, + { + method: 'git.commitDiff', + runtimeMethod: 'getRuntimeGitCommitDiff', + params: { worktree: 'id:wt-1', commitOid: 'c'.repeat(40), filePath: 'assets/logo.png' } + } + ] + +/** Stands in for orca-runtime-git.ts, which enforces the budget it is handed as its last argument. */ +function stubRuntime(runtimeMethod: string): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + [runtimeMethod]: vi.fn(async (...args: unknown[]) => { + const maxContentBytes = args.at(-1) + return assertGitDiffWithinTransportBudget( + OVERSIZED_DIFF, + typeof maxContentBytes === 'number' ? maxContentBytes : undefined + ) + }) + } as unknown as OrcaRuntimeService +} + +function budgetArgument(runtime: OrcaRuntimeService, runtimeMethod: string): unknown { + const spy = (runtime as unknown as Record>)[runtimeMethod]! + return spy.mock.calls[0]!.at(-1) +} + +function makeRequest(method: string, params: Record): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +async function dispatchRemote( + runtime: OrcaRuntimeService, + method: string, + params: Record, + clientKind: 'mobile' | 'runtime' +): Promise { + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + const replies: string[] = [] + await dispatcher.dispatchStreaming(makeRequest(method, params), (reply) => replies.push(reply), { + clientKind + }) + return JSON.parse(replies[0]!) as RpcResponse +} + +describe('remote git diff transport budget', () => { + it.each(CASES)('caps $method for a mobile client', async ({ method, runtimeMethod, params }) => { + const runtime = stubRuntime(runtimeMethod) + + const response = await dispatchRemote(runtime, method, params, 'mobile') + + expect(budgetArgument(runtime, runtimeMethod)).toBe(remoteRpcContentBudget('req-1')) + expect(response).toMatchObject({ + ok: false, + error: { code: 'diff_too_large', data: { maxBytes: remoteRpcContentBudget('req-1') } } + }) + }) + + it.each(CASES)( + 'caps $method for a remote desktop client', + async ({ method, runtimeMethod, params }) => { + const runtime = stubRuntime(runtimeMethod) + + const response = await dispatchRemote(runtime, method, params, 'runtime') + + expect(budgetArgument(runtime, runtimeMethod)).toBe(remoteRpcContentBudget('req-1')) + expect(response).toMatchObject({ ok: false, error: { code: 'diff_too_large' } }) + } + ) + + it('charges a long request id against the remote content budget', async () => { + const runtime = stubRuntime('getRuntimeGitDiff') + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + const requestId = '\u0001'.repeat(8_192) + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + { ...makeRequest('git.diff', CASES[0]!.params), id: requestId }, + (reply) => replies.push(reply), + { clientKind: 'mobile' } + ) + + expect(budgetArgument(runtime, 'getRuntimeGitDiff')).toBe(remoteRpcContentBudget(requestId)) + expect(Buffer.byteLength(replies[0]!, 'utf8')).toBeLessThanOrEqual( + REMOTE_RPC_MAX_CONTENT_BYTES + 8 * 1024 + ) + }) + + // Why: the in-process/Unix-socket context sets no clientKind, so desktop-local diffs keep full fidelity. + it.each(CASES)( + 'leaves $method uncapped for a local caller', + async ({ method, runtimeMethod, params }) => { + const runtime = stubRuntime(runtimeMethod) + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch(makeRequest(method, params)) + + expect(budgetArgument(runtime, runtimeMethod)).toBeUndefined() + expect(response).toMatchObject({ + ok: true, + result: { kind: 'binary', modifiedContent: OVERSIZED_BASE64 } + }) + } + ) + + // Why: an SSH host forwards its provider's payload verbatim, so the cap cannot rely on the far + // side clamping — this walks the real RuntimeGitCommands with an unclamped forwarded diff. + it('caps an SSH-forwarded diff a remote client requested', async () => { + sshMocks.getSshGitProvider.mockReturnValue({ + getDiff: vi.fn().mockResolvedValue(OVERSIZED_DIFF) + }) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: { id: 'wt-1', path: '/remote/repo' } as unknown as ResolvedRuntimeGitWorktree, + connectionId: 'conn-1' + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + const runtime = Object.assign(commands, { + getRuntimeId: () => 'test-runtime' + }) as unknown as OrcaRuntimeService + + const response = await dispatchRemote(runtime, 'git.diff', CASES[0]!.params, 'mobile') + + expect(response).toMatchObject({ ok: false, error: { code: 'diff_too_large' } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index e6ab9f3340d..92221d4bb33 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -179,7 +179,14 @@ describe('git RPC methods', () => { }) ) - expect(runtime.getRuntimeGitDiff).toHaveBeenCalledWith('id:wt-1', 'src/index.ts', false, true) + // A local dispatch sets no clientKind, so the transport budget stays undefined. + expect(runtime.getRuntimeGitDiff).toHaveBeenCalledWith( + 'id:wt-1', + 'src/index.ts', + false, + true, + undefined + ) expect(response).toMatchObject({ ok: true, result: { kind: 'text', modifiedContent: 'hello' } diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 167b82966f5..a11369ff9e0 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: this table is the runtime git RPC contract; splitting it would make method coverage harder to audit. */ import { defineMethod, type RpcMethod } from '../core' +import { remoteRpcContentBudget } from '../../../../shared/remote-rpc-content-budget' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { ResolvedSourceControlAiGenerationParams } from '../../../../shared/source-control-ai' import { @@ -28,6 +29,15 @@ import { WorktreeSelector } from './git-params' +// Why: clientKind is set only for WebSocket-transported requests, so desktop-local and in-process +// callers keep uncapped full-fidelity diffs. +function remoteDiffContentBudget( + clientKind: 'mobile' | 'runtime' | undefined, + requestId: string | undefined +): number | undefined { + return clientKind && requestId ? remoteRpcContentBudget(requestId) : undefined +} + type CommitMessageGenerationOverride = { commitMessageAi?: GlobalSettings['commitMessageAi'] sourceControlAi?: GlobalSettings['sourceControlAi'] @@ -158,12 +168,13 @@ export const GIT_METHODS: RpcMethod[] = [ defineMethod({ name: 'git.diff', params: GitDiff, - handler: async (params, { runtime }) => + handler: async (params, { runtime, clientKind, requestId }) => runtime.getRuntimeGitDiff( params.worktree, params.filePath, params.staged, - params.compareAgainstHead + params.compareAgainstHead, + remoteDiffContentBudget(clientKind, requestId) ) }), defineMethod({ @@ -236,24 +247,29 @@ export const GIT_METHODS: RpcMethod[] = [ defineMethod({ name: 'git.branchDiff', params: GitBranchDiff, - handler: async (params, { runtime }) => + handler: async (params, { runtime, clientKind, requestId }) => runtime.getRuntimeGitBranchDiff( params.worktree, params.compare, params.filePath, - params.oldPath + params.oldPath, + remoteDiffContentBudget(clientKind, requestId) ) }), defineMethod({ name: 'git.commitDiff', params: GitCommitDiff, - handler: async (params, { runtime }) => - runtime.getRuntimeGitCommitDiff(params.worktree, { - commitOid: params.commitOid, - parentOid: params.parentOid, - filePath: params.filePath, - oldPath: params.oldPath - }) + handler: async (params, { runtime, clientKind, requestId }) => + runtime.getRuntimeGitCommitDiff( + params.worktree, + { + commitOid: params.commitOid, + parentOid: params.parentOid, + filePath: params.filePath, + oldPath: params.oldPath + }, + remoteDiffContentBudget(clientKind, requestId) + ) }), defineMethod({ name: 'git.commit', diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts index 60a821c3d31..db613d7992b 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { normalizeExecutionHostId } from '../../../../shared/execution-host' import { defineMethod, type RpcMethod } from '../core' import { OptionalString, requiredString } from '../schemas' +import { projectRepoResultVisibilityForClient } from '../repo-visibility-projection' const ProjectProviderIdentity = z.object({ provider: z.literal('github'), @@ -129,29 +130,41 @@ export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [ defineMethod({ name: 'projectHostSetup.setupExistingFolder', params: ProjectHostSetupExistingFolder, - handler: async (params, { runtime }) => ({ - result: await runtime.setupProjectExistingFolder(params) + handler: async (params, context) => ({ + result: projectRepoResultVisibilityForClient( + await context.runtime.setupProjectExistingFolder(params), + context + ) }) }), defineMethod({ name: 'projectHostSetup.clone', params: ProjectHostSetupClone, - handler: async (params, { runtime }) => ({ - result: await runtime.setupProjectClone(params) + handler: async (params, context) => ({ + result: projectRepoResultVisibilityForClient( + await context.runtime.setupProjectClone(params), + context + ) }) }), defineMethod({ name: 'projectHostSetup.update', params: ProjectHostSetupUpdate, - handler: (params, { runtime }) => ({ - result: runtime.updateProjectHostSetup(params) + handler: (params, context) => ({ + result: projectRepoResultVisibilityForClient( + context.runtime.updateProjectHostSetup(params), + context + ) }) }), defineMethod({ name: 'projectHostSetup.delete', params: ProjectHostSetupDelete, - handler: (params, { runtime }) => ({ - result: runtime.deleteProjectHostSetup(params) + handler: (params, context) => ({ + result: projectRepoResultVisibilityForClient( + context.runtime.deleteProjectHostSetup(params), + context + ) }) }) ] diff --git a/src/main/runtime/rpc/methods/repo-update-schema.ts b/src/main/runtime/rpc/methods/repo-update-schema.ts index 4fff864abcd..b613b35d8ba 100644 --- a/src/main/runtime/rpc/methods/repo-update-schema.ts +++ b/src/main/runtime/rpc/methods/repo-update-schema.ts @@ -54,11 +54,11 @@ export function createRepoUpdateSchema( symlinkPaths: z.array(z.string()).optional(), issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(), forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(), - externalWorktreeVisibility: z.enum(['hide', 'show']).optional(), + externalWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(), externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(), importedExternalWorktreePaths: z.array(z.string()).optional(), - agentWorktreeVisibility: z.enum(['hide', 'show']).optional(), + agentWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(), customWorktreeVisibilitySources: z .unknown() .transform((value) => normalizeCustomWorktreeVisibilitySources(value)) diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 933c84a6d74..a267b705cb7 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -3,12 +3,74 @@ import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { REPO_METHODS } from './repo' +import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } } describe('repo RPC methods', () => { + it('projects inherited visibility for old clients but preserves inheritance for capable clients', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + enrichMissingRepoGitRemoteIdentities: vi.fn(), + listRepos: () => [{ id: 'repo-1', path: '/repo', externalWorktreeVisibilityLegacy: false }], + getClientSettings: () => ({ worktreeVisibilityDefaults: { external: 'show' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + const legacyReplies: string[] = [] + const currentReplies: string[] = [] + + await dispatcher.dispatchStreaming(makeRequest('repo.list'), (reply) => + legacyReplies.push(reply) + ) + await dispatcher.dispatchStreaming( + makeRequest('repo.list'), + (reply) => currentReplies.push(reply), + { clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] } + ) + + expect(JSON.parse(legacyReplies[0]!).result.repos[0].externalWorktreeVisibility).toBe('show') + expect( + JSON.parse(currentReplies[0]!).result.repos[0].externalWorktreeVisibility + ).toBeUndefined() + }) + + it('projects inherited visibility on repo mutation responses for old clients', async () => { + const repo = { + id: 'repo-1', + path: '/repo', + kind: 'git' as const, + externalWorktreeVisibilityLegacy: false + } + const runtime = { + getRuntimeId: () => 'test-runtime', + addRepo: vi.fn().mockResolvedValue(repo), + getClientSettings: () => ({ worktreeVisibilityDefaults: { external: 'show' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const legacyResponse = await dispatcher.dispatch( + makeRequest('repo.add', { path: '/repo', kind: 'git' }) + ) + const currentReplies: string[] = [] + await dispatcher.dispatchStreaming( + makeRequest('repo.add', { path: '/repo', kind: 'git' }), + (reply) => currentReplies.push(reply), + { clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] } + ) + const currentResponse = JSON.parse(currentReplies[0]!) + + expect(legacyResponse).toMatchObject({ + ok: true, + result: { repo: { externalWorktreeVisibility: 'show' } } + }) + expect(currentResponse).toMatchObject({ ok: true, result: { repo } }) + expect((currentResponse as { result: { repo: typeof repo } }).result.repo).not.toHaveProperty( + 'externalWorktreeVisibility' + ) + }) + it('updates project runtime preferences on the runtime server', async () => { const project = { id: 'project-1', @@ -362,6 +424,25 @@ describe('repo RPC methods', () => { }) }) + it('passes the null visibility sentinel through to clear a repository override', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateRepo: vi.fn().mockResolvedValue({ id: 'repo-1', path: '/srv/repo' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + updates: { externalWorktreeVisibility: null } + }) + ) + + expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', { + externalWorktreeVisibility: null + }) + }) + it('persists resolved GitHub upstream metadata updates', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 455192a8c11..7bf14b02fb1 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -4,6 +4,10 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods' import { FOLDER_WORKSPACE_METHODS } from './folder-workspace' import { createRepoUpdateSchema } from './repo-update-schema' +import { + projectRepoResultVisibilityForClient, + projectRepoVisibilityForClient +} from '../repo-visibility-projection' const RepoSelector = z.object({ repo: requiredString('Missing repo selector') @@ -108,9 +112,13 @@ export const REPO_METHODS: RpcMethod[] = [ defineMethod({ name: 'repo.list', params: null, - handler: (_params, { runtime }) => { - runtime.enrichMissingRepoGitRemoteIdentities?.() - return { repos: runtime.listRepos() } + handler: (_params, context) => { + context.runtime.enrichMissingRepoGitRemoteIdentities?.() + return { + repos: context.runtime + .listRepos() + .map((repo) => projectRepoVisibilityForClient(repo, context)) + } } }), ...PROJECT_RUNTIME_METHODS, @@ -141,8 +149,11 @@ export const REPO_METHODS: RpcMethod[] = [ defineMethod({ name: 'projectGroup.moveProject', params: ProjectGroupMoveProject, - handler: async (params, { runtime }) => ({ - repo: await runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order) + handler: async (params, context) => ({ + repo: projectRepoVisibilityForClient( + await context.runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order), + context + ) }) }), ...FOLDER_WORKSPACE_METHODS, @@ -177,15 +188,21 @@ export const REPO_METHODS: RpcMethod[] = [ defineMethod({ name: 'repo.add', params: RepoPath, - handler: async (params, { runtime }) => ({ - repo: await runtime.addRepo(params.path, params.kind) + handler: async (params, context) => ({ + repo: projectRepoVisibilityForClient( + await context.runtime.addRepo(params.path, params.kind), + context + ) }) }), defineMethod({ name: 'repo.create', params: RepoCreate, - handler: async (params, { runtime }) => - runtime.createRepo(params.parentPath, params.name, params.kind) + handler: async (params, context) => + projectRepoResultVisibilityForClient( + await context.runtime.createRepo(params.parentPath, params.name, params.kind), + context + ) }), defineMethod({ name: 'repo.gitAvailable', @@ -195,22 +212,30 @@ export const REPO_METHODS: RpcMethod[] = [ defineMethod({ name: 'repo.clone', params: RepoClone, - handler: async (params, { runtime }) => ({ - repo: await runtime.cloneRepo(params.url, params.destination) + handler: async (params, context) => ({ + repo: projectRepoVisibilityForClient( + await context.runtime.cloneRepo(params.url, params.destination), + context + ) }) }), defineMethod({ name: 'repo.show', params: RepoSelector, - handler: async (params, { runtime }) => ({ repo: await runtime.showRepo(params.repo) }) + handler: async (params, context) => ({ + repo: projectRepoVisibilityForClient(await context.runtime.showRepo(params.repo), context) + }) }), defineMethod({ name: 'repo.update', params: RepoUpdate, - handler: async (params, { runtime }) => ({ - repo: await runtime.updateRepo( - params.repo, - params.updates as Parameters[1] + handler: async (params, context) => ({ + repo: projectRepoVisibilityForClient( + await context.runtime.updateRepo( + params.repo, + params.updates as Parameters[1] + ), + context ) }) }), @@ -227,8 +252,11 @@ export const REPO_METHODS: RpcMethod[] = [ defineMethod({ name: 'repo.setBaseRef', params: RepoSetBaseRef, - handler: async (params, { runtime }) => ({ - repo: await runtime.setRepoBaseRef(params.repo, params.ref) + handler: async (params, context) => ({ + repo: projectRepoVisibilityForClient( + await context.runtime.setRepoBaseRef(params.repo, params.ref), + context + ) }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts index 2af2ffe0ed8..a8fe8ca00c7 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts @@ -1,5 +1,6 @@ import type { z } from 'zod' import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' +import type { WorkspaceCleanupUIState } from '../../../../shared/workspace-cleanup' import type { UiUpdateFieldsSchema } from './client-ui-schemas' import type { AssertNoMissingKeys, AssertNoMissingValues } from './ui-state-schema-parity' @@ -34,3 +35,19 @@ const _uiUpdateValueParity: AssertNoMissingValues< z.input > = true void _uiUpdateValueParity + +// Why a nested pair: the guards above only walk PersistedUIState's OWN keys, and +// a structural `extends` is satisfied by extra properties — so a sub-key added +// to workspaceCleanup (e.g. `browse`) and never added to its `.strict()` schema +// would keep both green while every paired client's ui.set payload is rejected. +type WorkspaceCleanupSchema = NonNullable['workspaceCleanup']> +const _workspaceCleanupParity: AssertNoMissingKeys< + WorkspaceCleanupUIState, + WorkspaceCleanupSchema +> = true +void _workspaceCleanupParity +const _workspaceCleanupValueParity: AssertNoMissingValues< + WorkspaceCleanupUIState, + WorkspaceCleanupSchema +> = true +void _workspaceCleanupValueParity diff --git a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts new file mode 100644 index 00000000000..090a7981147 --- /dev/null +++ b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { UiUpdate } from './client-ui-schemas' +import { WorkspaceCleanup } from './workspace-cleanup-ui-schema' +import { createDefaultWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' + +const DISMISSAL = { + worktreeId: 'wt-1', + dismissedAt: 1700000000000, + fingerprint: '2|main|abc|clean|19675', + classifierVersion: 2 +} + +describe('workspaceCleanup client-ui schema', () => { + it('accepts a default browse state', () => { + const browse = createDefaultWorkspaceCleanupBrowseState() + const parsed = WorkspaceCleanup.parse({ dismissals: { 'wt-1': DISMISSAL }, browse }) + + expect(parsed.browse).toEqual(browse) + expect(parsed.dismissals['wt-1']).toEqual(DISMISSAL) + }) + + it('still accepts a dismissals-only payload from a client that predates browse', () => { + expect(WorkspaceCleanup.parse({ dismissals: {} })).toEqual({ dismissals: {} }) + }) + + // The wire contract: a NEWER client's filter groups must never fail an older + // host's schema, or the whole ui.set payload is refused (dismissals included). + it('accepts a browse state carrying unknown future fields and narrows it', () => { + const parsed = WorkspaceCleanup.parse({ + dismissals: {}, + browse: { + version: 7, + activePresetId: 'suggested', + futureTopLevelField: ['whatever'], + filters: { + query: 'kept', + futureGroup: { enabled: true }, + activity: { idleSignal: 'invented-signal', idleMinDays: 14 } + }, + sort: { field: 'invented-column', direction: 'desc' }, + customPresets: [] + } + }) + + expect(parsed.browse?.filters.query).toBe('kept') + expect(parsed.browse?.filters.activity.idleMinDays).toBe(14) + expect(parsed.browse?.filters.activity.idleSignal).toBe('last-visited') + expect(parsed.browse?.sort).toEqual({ field: 'last-activity', direction: 'desc' }) + expect(parsed.browse).not.toHaveProperty('activePresetId') + expect(parsed.browse).not.toHaveProperty('customPresets') + expect(parsed.browse).not.toHaveProperty('futureTopLevelField') + }) + + it('carries browse through the full UiUpdate payload without dropping siblings', () => { + const browse = createDefaultWorkspaceCleanupBrowseState() + browse.filters.query = 'stale' + + const parsed = UiUpdate.parse({ + sidebarWidth: 320, + workspaceCleanup: { dismissals: { 'wt-1': DISMISSAL }, browse } + }) + + expect(parsed.sidebarWidth).toBe(320) + expect(parsed.workspaceCleanup?.browse?.filters.query).toBe('stale') + }) +}) diff --git a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts new file mode 100644 index 00000000000..79984b55721 --- /dev/null +++ b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' +import { + normalizeWorkspaceCleanupBrowseState, + type WorkspaceCleanupBrowseState +} from '../../../../shared/workspace-cleanup-browse-state' + +const WorkspaceCleanupDismissal = z.object({ + worktreeId: z.string(), + dismissedAt: z.number().finite(), + fingerprint: z.string(), + classifierVersion: z.number().finite() +}) + +/** + * Deliberately unvalidated shape, then normalized: the filter groups must NOT be + * strict or enumerated here. A newer client sends filters this build has never + * heard of, and a per-field zod shape would reject the whole `ui.set` payload + * instead of persisting the parts the host does understand. The shared + * normalizer never throws and degrades field by field, so an older host narrows + * the state rather than refusing it. + */ +const WorkspaceCleanupBrowse = z + .custom() + .transform((value) => normalizeWorkspaceCleanupBrowseState(value)) + +export const WorkspaceCleanup = z.object({ + dismissals: z.record(z.string(), WorkspaceCleanupDismissal), + browse: WorkspaceCleanupBrowse.optional() +}) diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts new file mode 100644 index 00000000000..a5e741fc78e --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -0,0 +1,48 @@ +import { defineMethod, type RpcMethod } from '../core' +import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot' +import { supportsWorktreeVisibilitySourceDefaults } from '../worktree-visibility-client-capability' +import { + WorktreeDetectedListParams, + WorktreeListParams, + WorktreePsParams +} from './worktree-schemas' + +export const WORKTREE_CATALOG_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'worktree.ps', + params: WorktreePsParams, + handler: async (params, context) => { + const result = await context.runtime.getWorktreePs( + params.limit, + supportsWorktreeVisibilitySourceDefaults( + context, + params.supportsWorktreeVisibilitySourceDefaults + ) + ) + // Why: callers that never send the field get the byte-exact legacy response. + return params.afterSnapshotId === undefined + ? result + : resolveWorktreeCatalogSnapshot(result, params.afterSnapshotId) + } + }), + defineMethod({ + name: 'worktree.list', + params: WorktreeListParams, + handler: async (params, context) => + context.runtime.listManagedWorktrees( + params.repo, + params.limit, + supportsWorktreeVisibilitySourceDefaults(context) + ) + }), + defineMethod({ + name: 'worktree.detectedList', + params: WorktreeDetectedListParams, + handler: async (params, context) => + context.runtime.listDetectedManagedWorktrees( + params.repo, + undefined, + supportsWorktreeVisibilitySourceDefaults(context) + ) + }) +] diff --git a/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts b/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts index c7efa0bbe43..7bf9cd2734c 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' import { WORKTREE_METHODS } from './worktree' +import { WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' function makeRuntime() { return { @@ -57,4 +58,43 @@ describe('worktree.ps catalog snapshots', () => { }) expect(runtime.getWorktreePs).toHaveBeenCalledTimes(2) }) + + it('gates source-default worktree projection by client capability', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + await dispatcher.dispatchStreaming( + { + id: 'legacy', + authToken: 'token', + method: 'worktree.ps', + params: { limit: 10_000 } + }, + () => {}, + { clientCapabilities: [] } + ) + await dispatcher.dispatchStreaming( + { + id: 'current', + authToken: 'token', + method: 'worktree.ps', + params: { limit: 10_000 } + }, + () => {}, + { clientCapabilities: [WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY] } + ) + await dispatcher.dispatchStreaming( + { + id: 'mobile', + authToken: 'token', + method: 'worktree.ps', + params: { limit: 10_000, supportsWorktreeVisibilitySourceDefaults: true } + }, + () => {}, + { clientCapabilities: [] } + ) + + expect(runtime.getWorktreePs).toHaveBeenNthCalledWith(1, 10_000, false) + expect(runtime.getWorktreePs).toHaveBeenNthCalledWith(2, 10_000, true) + expect(runtime.getWorktreePs).toHaveBeenNthCalledWith(3, 10_000, true) + }) }) diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index d401ddd414d..79428938705 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -58,7 +58,8 @@ export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams export const WorktreePsParams = z.object({ limit: OptionalFiniteNumber, - afterSnapshotId: z.string().min(1).max(128).nullable().optional() + afterSnapshotId: z.string().min(1).max(128).nullable().optional(), + supportsWorktreeVisibilitySourceDefaults: z.literal(true).optional() }) export const WorktreeSortOrder = z.object({ diff --git a/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts b/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts new file mode 100644 index 00000000000..8bb7e1d081e --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' +import { + normalizeCustomWorktreeVisibilitySources, + normalizeWorktreeVisibilitySourcePreferences +} from '../../../../shared/worktree/visibility-sources' + +export const WorktreeVisibilityDefaultsUpdate = z + .object({ + external: z.enum(['hide', 'show']).optional(), + customSources: z + .unknown() + .transform((value) => normalizeCustomWorktreeVisibilitySources(value)) + .optional(), + sourcePreferences: z + .unknown() + .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value)) + .optional() + }) + .strict() diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 3d2a05b523f..1ff3732ea3f 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -5,17 +5,13 @@ import { } from '../../../automations/workspace-provenance' import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance' import { defineMethod, type RpcMethod } from '../core' -import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' import { WorktreeCreate, - WorktreeDetectedListParams, WorktreeActivate, WorktreeForceDeleteBranch, - WorktreeListParams, WorktreePrefetchCreateBase, - WorktreePsParams, WorktreeRemove, WorktreeResolveMrBase, WorktreeResolvePrBase, @@ -24,30 +20,10 @@ import { WorktreeSortOrder, WorktreeTeardownMissingTerminalsParams } from './worktree-schemas' +import { WORKTREE_CATALOG_METHODS } from './worktree-catalog-methods' export const WORKTREE_METHODS: RpcMethod[] = [ - defineMethod({ - name: 'worktree.ps', - params: WorktreePsParams, - handler: async (params, { runtime }) => { - const result = await runtime.getWorktreePs(params.limit) - // Why: callers that never send the field get the byte-exact legacy response. - if (params.afterSnapshotId === undefined) { - return result - } - return resolveWorktreeCatalogSnapshot(result, params.afterSnapshotId) - } - }), - defineMethod({ - name: 'worktree.list', - params: WorktreeListParams, - handler: async (params, { runtime }) => runtime.listManagedWorktrees(params.repo, params.limit) - }), - defineMethod({ - name: 'worktree.detectedList', - params: WorktreeDetectedListParams, - handler: async (params, { runtime }) => runtime.listDetectedManagedWorktrees(params.repo) - }), + ...WORKTREE_CATALOG_METHODS, defineMethod({ name: 'worktree.teardownMissingTerminals', params: WorktreeTeardownMissingTerminalsParams, diff --git a/src/main/runtime/rpc/remote-outbound-budget-close.ts b/src/main/runtime/rpc/remote-outbound-budget-close.ts new file mode 100644 index 00000000000..cc704e2fcfc --- /dev/null +++ b/src/main/runtime/rpc/remote-outbound-budget-close.ts @@ -0,0 +1,12 @@ +import type { EventProps } from '../../../shared/telemetry-events' +import { track } from '../../telemetry/client' + +type OutboundBudgetEmitter = EventProps<'remote_outbound_budget_close'>['emitter'] + +export function trackRemoteOutboundBudgetClose(emitter: OutboundBudgetEmitter): void { + try { + track('remote_outbound_budget_close', { emitter }) + } catch { + // Telemetry must not prevent closing an unsafe socket. + } +} diff --git a/src/main/runtime/rpc/repo-visibility-projection.ts b/src/main/runtime/rpc/repo-visibility-projection.ts new file mode 100644 index 00000000000..05c0db0b715 --- /dev/null +++ b/src/main/runtime/rpc/repo-visibility-projection.ts @@ -0,0 +1,35 @@ +import { + effectiveExternalWorktreeVisibility, + isLegacyRepoForExternalWorktreeVisibility +} from '../../../shared/external-worktree-visibility' +import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { Repo } from '../../../shared/repo-types' +import type { RpcContext } from './core' + +type RepoProjectionContext = Pick + +export function projectRepoVisibilityForClient(repo: Repo, context: RepoProjectionContext): Repo { + if ( + context.clientCapabilities?.includes(WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY) || + repo.kind === 'folder' || + repo.externalWorktreeVisibility !== undefined + ) { + return repo + } + return { + ...repo, + externalWorktreeVisibility: effectiveExternalWorktreeVisibility( + repo, + isLegacyRepoForExternalWorktreeVisibility(repo), + context.runtime.getClientSettings?.().worktreeVisibilityDefaults + ) + } +} + +export function projectRepoResultVisibilityForClient( + result: T, + context: RepoProjectionContext +): T { + const repo = (result as { repo?: Repo }).repo + return repo ? { ...result, repo: projectRepoVisibilityForClient(repo, context) } : result +} diff --git a/src/main/runtime/rpc/worktree-visibility-client-capability.ts b/src/main/runtime/rpc/worktree-visibility-client-capability.ts new file mode 100644 index 00000000000..eaa1997819c --- /dev/null +++ b/src/main/runtime/rpc/worktree-visibility-client-capability.ts @@ -0,0 +1,13 @@ +import { WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { RpcContext } from './core' + +export function supportsWorktreeVisibilitySourceDefaults( + context: Pick, + requestSupport = false +): boolean { + return ( + requestSupport || + context.clientCapabilities === undefined || + context.clientCapabilities.includes(WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY) + ) +} diff --git a/src/main/runtime/runtime-graph-reload-lifecycle.test.ts b/src/main/runtime/runtime-graph-reload-lifecycle.test.ts new file mode 100644 index 00000000000..cdd64fd6493 --- /dev/null +++ b/src/main/runtime/runtime-graph-reload-lifecycle.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + RuntimeGraphReloadLifecycle, + type RuntimeGraphReloadSettlement +} from './runtime-graph-reload-lifecycle' + +describe('RuntimeGraphReloadLifecycle', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('records success, failure, cancellation, and timeout as terminal outcomes', async () => { + vi.useFakeTimers() + const settlements: RuntimeGraphReloadSettlement[] = [] + const timeouts: number[] = [] + const lifecycle = new RuntimeGraphReloadLifecycle({ + timeoutMs: 100, + onSettled: (settlement) => settlements.push(settlement), + onTimeout: (revision) => timeouts.push(revision) + }) + + const success = lifecycle.begin(1) + expect(lifecycle.settle(success, 'success')).toBe(true) + const failure = lifecycle.begin(1) + expect(lifecycle.settle(failure, 'failure')).toBe(true) + const cancelled = lifecycle.begin(1) + expect(lifecycle.settle(cancelled, 'cancelled')).toBe(true) + const timeout = lifecycle.begin(1) + await vi.advanceTimersByTimeAsync(100) + + expect(settlements.map(({ revision, outcome }) => ({ revision, outcome }))).toEqual([ + { revision: success, outcome: 'success' }, + { revision: failure, outcome: 'failure' }, + { revision: cancelled, outcome: 'cancelled' }, + { revision: timeout, outcome: 'timeout' } + ]) + expect(timeouts).toEqual([timeout]) + expect(lifecycle.getActiveRevision()).toBeNull() + }) + + it('cancels a superseded generation and ignores its stale completion and timeout', async () => { + vi.useFakeTimers() + const settlements: RuntimeGraphReloadSettlement[] = [] + const timeouts: number[] = [] + const lifecycle = new RuntimeGraphReloadLifecycle({ + timeoutMs: 100, + onSettled: (settlement) => settlements.push(settlement), + onTimeout: (revision) => timeouts.push(revision) + }) + + const first = lifecycle.begin(1) + await vi.advanceTimersByTimeAsync(60) + const second = lifecycle.begin(1) + + expect(lifecycle.settle(first, 'success')).toBe(false) + await vi.advanceTimersByTimeAsync(40) + expect(timeouts).toEqual([]) + expect(lifecycle.getActiveRevision()).toBe(second) + + await vi.advanceTimersByTimeAsync(60) + expect(timeouts).toEqual([second]) + expect(settlements.map(({ revision, outcome }) => ({ revision, outcome }))).toEqual([ + { revision: first, outcome: 'cancelled' }, + { revision: second, outcome: 'timeout' } + ]) + }) + + it('keeps a generation started by a cancellation observer active', async () => { + vi.useFakeTimers() + const settlements: RuntimeGraphReloadSettlement[] = [] + const timeouts: number[] = [] + let nestedRevision: number | null = null + let didReenter = false + let lifecycle!: RuntimeGraphReloadLifecycle + lifecycle = new RuntimeGraphReloadLifecycle({ + timeoutMs: 100, + onSettled: (settlement) => { + settlements.push(settlement) + if (settlement.outcome === 'cancelled' && !didReenter) { + didReenter = true + nestedRevision = lifecycle.begin(3) + } + }, + onTimeout: (revision) => timeouts.push(revision) + }) + + const first = lifecycle.begin(1) + const replacement = lifecycle.begin(2) + + expect(replacement).toBe(2) + expect(nestedRevision).toBe(3) + expect(lifecycle.getActiveRevision()).toBe(nestedRevision) + expect(lifecycle.settle(replacement, 'success')).toBe(false) + + await vi.advanceTimersByTimeAsync(100) + + expect(timeouts).toEqual([nestedRevision]) + expect(settlements.map(({ revision, outcome }) => ({ revision, outcome }))).toEqual([ + { revision: first, outcome: 'cancelled' }, + { revision: replacement, outcome: 'cancelled' }, + { revision: nestedRevision, outcome: 'timeout' } + ]) + }) +}) diff --git a/src/main/runtime/runtime-graph-reload-lifecycle.ts b/src/main/runtime/runtime-graph-reload-lifecycle.ts new file mode 100644 index 00000000000..36253d544ea --- /dev/null +++ b/src/main/runtime/runtime-graph-reload-lifecycle.ts @@ -0,0 +1,87 @@ +export const RUNTIME_GRAPH_RELOAD_TIMEOUT_MS = 15_000 + +export type RuntimeGraphReloadOutcome = 'success' | 'failure' | 'cancelled' | 'timeout' + +export type RuntimeGraphReloadSettlement = Readonly<{ + revision: number + windowId: number + outcome: RuntimeGraphReloadOutcome + durationMs: number +}> + +type ActiveRuntimeGraphReload = Readonly<{ + revision: number + windowId: number + startedAt: number + timer: ReturnType +}> + +export class RuntimeGraphReloadLifecycle { + private revision = 0 + private active: ActiveRuntimeGraphReload | null = null + + constructor( + private readonly options: { + timeoutMs: number + onSettled?: (settlement: RuntimeGraphReloadSettlement) => void + onTimeout?: (revision: number, windowId: number) => void + } + ) {} + + begin(windowId: number): number { + const cancelled = this.active ? this.finish(this.active.revision, 'cancelled') : null + + const revision = ++this.revision + const startedAt = Date.now() + const timer = setTimeout(() => { + const settlement = this.finish(revision, 'timeout') + if (!settlement) { + return + } + this.options.onSettled?.(settlement) + this.options.onTimeout?.(revision, windowId) + }, this.options.timeoutMs) + timer.unref?.() + this.active = { revision, windowId, startedAt, timer } + if (cancelled) { + this.options.onSettled?.(cancelled) + } + return revision + } + + settle(revision: number, outcome: RuntimeGraphReloadOutcome): boolean { + const settlement = this.finish(revision, outcome) + if (!settlement) { + return false + } + this.options.onSettled?.(settlement) + return true + } + + private finish( + revision: number, + outcome: RuntimeGraphReloadOutcome + ): RuntimeGraphReloadSettlement | null { + const active = this.active + if (!active || active.revision !== revision) { + return null + } + + clearTimeout(active.timer) + this.active = null + return { + revision, + windowId: active.windowId, + outcome, + durationMs: Math.max(0, Date.now() - active.startedAt) + } + } + + settleActive(outcome: RuntimeGraphReloadOutcome): boolean { + return this.active ? this.settle(this.active.revision, outcome) : false + } + + getActiveRevision(): number | null { + return this.active?.revision ?? null + } +} diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 5ccffe24550..9fde32745e5 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -13,6 +13,7 @@ import { OrchestrationDb } from './orchestration/db' import * as runtimeMetadataModule from './runtime-metadata' import { readRuntimeMetadata, writeRuntimeMetadata } from './runtime-metadata' import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc' +import { remoteRpcContentBudget } from '../../shared/remote-rpc-content-budget' import { parsePairingCode } from '../../shared/pairing' import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client' import { @@ -3299,7 +3300,14 @@ describe('OrcaRuntimeRpcServer', () => { expect(abortRuntimeGitRebase).toHaveBeenCalledWith('id:wt-1') expect(bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['c.ts']) expect(openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true) - expect(getRuntimeGitDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', false, undefined) + // A mobile WebSocket client is transport-capped; a local caller gets undefined here. + expect(getRuntimeGitDiff).toHaveBeenCalledWith( + 'id:wt-1', + 'docs/readme.md', + false, + undefined, + remoteRpcContentBudget('req_git_diff') + ) expect(browserTabCreate).toHaveBeenCalledWith({ worktree: 'id:wt-1', url: 'about:blank' }) expect(browserSetViewport).toHaveBeenCalledWith({ worktree: 'id:wt-1', diff --git a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts index ae845e42e67..1aa49b76d8e 100644 --- a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -39,10 +39,10 @@ vi.mock('./repo-worktree-admin-fingerprint', () => ({ import { OrcaRuntimeService, - RESOLVED_WORKTREE_REPO_TIMEOUT_MS, WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS, WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS } from './orca-runtime' +import { RESOLVED_WORKTREE_REPO_TIMEOUT_MS } from './repo-worktree-row-resolution' const REPO_ID = 'repo-local' const REPO_PATH = '/Users/me/dev/app' @@ -551,3 +551,76 @@ describe('worktree scan admin-fingerprint gate', () => { expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(1) }) }) + +describe('scoped explicit worktree-id resolution', () => { + beforeEach(() => { + getSshGitProviderMock.mockReset() + listWorktreesStrictMock.mockReset() + listWorktreesStrictMock.mockImplementation(async (repoPath: string) => [ + { path: repoPath, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { + path: `${repoPath}-feature`, + head: 'def', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]) + readRepoWorktreeAdminFingerprintMock.mockReset() + readRepoWorktreeAdminFingerprintMock.mockResolvedValue('fp-1') + }) + + function scannedRepoPaths(): string[] { + return listWorktreesStrictMock.mock.calls.map((call) => call[0] as string) + } + + it('scans only the owning repo for an id: selector on a cold cache', async () => { + const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never) + const resolve = (selector: string): Promise<{ id: string }> => + ( + runtime as unknown as { resolveWorktreeSelector: (s: string) => Promise<{ id: string }> } + ).resolveWorktreeSelector(selector) + + const resolved = await resolve(`id:${MAIN_WORKTREE_ID}`) + + expect(resolved.id).toBe(MAIN_WORKTREE_ID) + expect(scannedRepoPaths()).toEqual([REPO_PATH]) + }) + + it('still finds worktrees in other repos through the fleet path', async () => { + const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never) + const resolve = (selector: string): Promise<{ id: string }> => + ( + runtime as unknown as { resolveWorktreeSelector: (s: string) => Promise<{ id: string }> } + ).resolveWorktreeSelector(selector) + + const otherId = `${REPO_ID}-3::${REPO_PATH}-3` + const resolved = await resolve(`id:${otherId}`) + + expect(resolved.id).toBe(otherId) + expect(scannedRepoPaths()).toEqual([`${REPO_PATH}-3`]) + }) + + it('keeps cross-repo selectors on the fleet path so ambiguity still throws', async () => { + const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never) + const resolve = (selector: string): Promise => + ( + runtime as unknown as { resolveWorktreeSelector: (s: string) => Promise } + ).resolveWorktreeSelector(selector) + + // `main` is checked out in every repo, so a branch selector must refuse rather than pick one. + await expect(resolve('branch:main')).rejects.toThrow('selector_ambiguous') + expect(new Set(scannedRepoPaths()).size).toBe(10) + }) + + it('falls back to the fleet path when the id names no registered repo', async () => { + const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never) + const resolve = (selector: string): Promise => + ( + runtime as unknown as { resolveWorktreeSelector: (s: string) => Promise } + ).resolveWorktreeSelector(selector) + + await expect(resolve('id:missing-repo::/nowhere')).rejects.toThrow('selector_not_found') + expect(new Set(scannedRepoPaths()).size).toBe(10) + }) +}) diff --git a/src/main/sidecar-snapshot-file.ts b/src/main/sidecar-snapshot-file.ts new file mode 100644 index 00000000000..f7c0f23c0e4 --- /dev/null +++ b/src/main/sidecar-snapshot-file.ts @@ -0,0 +1,51 @@ +// Why a sidecar next to orca-data.json: scan snapshots rewrite wholesale and can reach hundreds +// of KB; folding them into orca-data.json would rewrite the whole state file per scan (the +// githubCache sidecar precedent). Best-effort by design: a lost snapshot only costs a rescan. +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + durableWriteTempPath, + removeStaleDurableWriteTempFiles, + writeFileDurable +} from './durable-file-write' + +const queues = new Map>() +const staleTempCleanups = new Map>() +const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 + +export function sidecarSnapshotFile(snapshotDirectory: string, fileName: string): string { + return join(snapshotDirectory, fileName) +} + +/** Serialize tasks per sidecar so read-modify-write patch/prune cycles cannot interleave. */ +export function withSidecarSnapshotQueue(file: string, task: () => Promise): Promise { + const previous = queues.get(file) ?? Promise.resolve() + const run = previous.then(task, task) + queues.set( + file, + run.then( + () => undefined, + () => undefined + ) + ) + return run +} + +/** Parsed sidecar contents, or null when the file is missing or not valid JSON. */ +export async function readSidecarSnapshot(file: string): Promise { + try { + return JSON.parse(await readFile(file, 'utf-8')) as unknown + } catch { + return null + } +} + +export async function writeSidecarSnapshot(file: string, payload: unknown): Promise { + let cleanup = staleTempCleanups.get(file) + if (!cleanup) { + cleanup = removeStaleDurableWriteTempFiles(file, { minimumAgeMs: STALE_TEMP_AGE_MS }) + staleTempCleanups.set(file, cleanup) + } + await cleanup + await writeFileDurable(durableWriteTempPath(file), file, JSON.stringify(payload)) +} diff --git a/src/main/ssh/ssh-file-stream-read-cap.ts b/src/main/ssh/ssh-file-stream-read-cap.ts new file mode 100644 index 00000000000..e5bdce97e5a --- /dev/null +++ b/src/main/ssh/ssh-file-stream-read-cap.ts @@ -0,0 +1,10 @@ +import type { FileReadLimits } from '../providers/types' + +const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 +const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 + +export function sshFileStreamReadCap(isBinary: boolean, limits?: FileReadLimits): number { + const defaultCap = isBinary ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + const requestedCap = isBinary ? limits?.maxBinaryBytes : limits?.maxTextBytes + return requestedCap === undefined ? defaultCap : Math.min(defaultCap, requestedCap) +} diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts index f61939d6c2f..568ab195dc8 100644 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ b/src/main/ssh/ssh-filesystem-stream-reader.ts @@ -1,17 +1,15 @@ import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { STREAM_CHUNK_SIZE, JsonRpcErrorCode, RelayErrorCode } from './relay-protocol' -import type { FileReadResult } from '../providers/types' +import type { FileReadLimits, FileReadResult } from '../providers/types' import { createSshFileStreamInactivityDeadline, SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS } from './ssh-file-stream-inactivity-deadline' +import { sshFileStreamReadCap } from './ssh-file-stream-read-cap' const RESULT_ENCODING_BASE64 = 'base64' const SENTINEL_STREAM_ID = -1 -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 -const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 - type StreamMetadataResponse = { streamId?: number totalSize: number @@ -37,9 +35,14 @@ export class StreamProtocolError extends Error { } } +// Why: exceeding a cap the caller itself set is a size verdict, not a protocol fault — callers +// translate it into their own too-large error rather than leaking the raw stream message. +export class FileReadCapExceededError extends StreamProtocolError {} + export async function readFileViaStream( mux: SshChannelMultiplexer, - filePath: string + filePath: string, + limits?: FileReadLimits ): Promise { // Why: subscribe BEFORE awaiting the metadata response so a chunk arriving // immediately after the response cannot beat the listener registration. @@ -307,11 +310,11 @@ export async function readFileViaStream( return } - const cap = metadata.isBinary ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + const cap = sshFileStreamReadCap(metadata.isBinary, limits) if (metadata.totalSize < 0 || metadata.totalSize > cap) { streamIdRef.current = metadata.streamId fail( - new StreamProtocolError( + new FileReadCapExceededError( `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}` ) ) diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 28fbeb4cd3d..e18f8a33e39 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -121,6 +121,7 @@ type MainWindowStub = { once: MockFn webContents: { id?: number + getURL: MockFn isDestroyed?: MockFn isLoadingMainFrame: MockFn on: MockFn @@ -137,6 +138,8 @@ type RuntimeStub = { attachWindow: MockFn setNotifier: MockFn markRendererReloading: MockFn + markRendererReloadCancelled: MockFn + markGraphReloadFailed: MockFn markGraphUnavailable: MockFn } @@ -150,6 +153,7 @@ function createMainWindow( once: vi.fn(), webContents: { id: 1, + getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), isDestroyed: vi.fn(() => false), isLoadingMainFrame: vi.fn(() => true), on: vi.fn(), @@ -165,8 +169,9 @@ function createMainWindow( function createStore(): Store & { flushPendingAsync: MockFn } { return { + getProfileStorageDirectory: vi.fn(() => '/profile-a'), flushPendingAsync: vi.fn(() => Promise.resolve()) - } as Store & { flushPendingAsync: MockFn } + } as unknown as Store & { flushPendingAsync: MockFn } } function createRuntime(): RuntimeStub { @@ -174,6 +179,8 @@ function createRuntime(): RuntimeStub { attachWindow: vi.fn(), setNotifier: vi.fn(), markRendererReloading: vi.fn(), + markRendererReloadCancelled: vi.fn(), + markGraphReloadFailed: vi.fn(), markGraphUnavailable: vi.fn() } } @@ -797,6 +804,21 @@ describe('attachMainWindowServices', () => { ) }) + it('marks renderer process loss as a graph reload failure', () => { + const mainWindow = createMainWindow() + const runtime = createRuntime() + attachMainWindowServices(mainWindow as never, createStore(), runtime as never) + + const handlers = mainWindow.webContents.on.mock.calls + .filter(([event]) => event === 'render-process-gone') + .map(([, handler]) => handler as () => void) + for (const handler of handlers) { + handler() + } + + expect(runtime.markGraphReloadFailed).toHaveBeenCalledWith(1, 'renderer-process-gone') + }) + it('accepts terminal reveal replies only from the main window renderer', async () => { const sendMock = vi.fn() const mainWindow = createMainWindow({ send: sendMock }) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 0fbd850f06b..4ad095bef15 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -64,6 +64,8 @@ import { setWorktreeBaseDirectoryWatcherSyncContext } from '../ipc/worktree-base-directory-watcher' import { logStartupMilestone } from '../startup/startup-diagnostics' +import { createRuntimeRendererNotificationSender } from './runtime-renderer-notification-sender' +import { registerRendererDocumentNavigation } from './renderer-document-navigation' const UPDATER_SETUP_FALLBACK_MS = 15_000 @@ -318,11 +320,13 @@ function registerRuntimeWindowLifecycle( const notifierToken = ++runtimeNotifierTokenCounter activeRuntimeNotifierToken = notifierToken runtime.attachWindow(mainWindow.id) - const send = (channel: string, ...args: unknown[]): void => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send(channel, ...args) - } - } + const mainWebContents = mainWindow.webContents + const rendererNotifications = createRuntimeRendererNotificationSender({ + isWindowDestroyed: () => mainWindow.isDestroyed(), + webContents: mainWebContents, + onFailure: (reason) => runtime.markGraphReloadFailed(mainWindow.id, reason) + }) + const send = rendererNotifications.send runtime.setNotifier({ worktreesChanged: (repoId, renamed) => { // Why: clear scan caches before the renderer handles this event, so it can't read stale TTL entries after a mutation. @@ -401,7 +405,7 @@ function registerRuntimeWindowLifecycle( }) } ipcMain.on('terminal:tabCreateReply', handler) - send('ui:createTerminal', { + const sent = send('ui:createTerminal', { requestId, worktreeId, ptyId: opts.ptyId, @@ -424,6 +428,11 @@ function registerRuntimeWindowLifecycle( : {}), ...(opts.focus !== undefined ? { focus: opts.focus } : {}) }) + if (!sent) { + clearTimeout(timer) + ipcMain.removeListener('terminal:tabCreateReply', handler) + reject(new Error('runtime_unavailable')) + } }), resolveLegacyWorkerTerminalRecovery: (paneKey, resolution, ptyId) => send('agentStatus:legacyWorkerTerminalRecovery', { @@ -489,11 +498,23 @@ function registerRuntimeWindowLifecycle( browserDriverChanged: (browserPageId, driver) => send('runtime:browserDriverChanged', { browserPageId, driver }) }) - // Why: fail closed during renderer reload so CLI calls can't act on stale terminal mappings. - mainWindow.webContents.on('did-start-loading', () => { - runtime.markRendererReloading(mainWindow.id) + registerRendererDocumentNavigation(mainWebContents, () => { + rendererNotifications.onMainFrameReloadStarted() + const fence = runtime.markRendererReloading(mainWindow.id) + return () => { + if (fence && runtime.markRendererReloadCancelled(mainWindow.id, fence)) { + rendererNotifications.onMainFrameReloadCancelled() + } + } + }) + mainWebContents.on('did-finish-load', () => { + rendererNotifications.onMainFrameLoadFinished() + }) + mainWebContents.on('render-process-gone', () => { + rendererNotifications.onRendererProcessGone() }) mainWindow.on('closed', () => { + rendererNotifications.close() runtime.markGraphUnavailable(mainWindow.id) if (activeRuntimeNotifierToken === notifierToken) { // Why: the notifier closes over the window; clear it in the no-window gap so the runtime can't retain destroyed graphs. diff --git a/src/main/window/renderer-document-navigation.test.ts b/src/main/window/renderer-document-navigation.test.ts new file mode 100644 index 00000000000..8e4a1fee208 --- /dev/null +++ b/src/main/window/renderer-document-navigation.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest' +import { registerRendererDocumentNavigation } from './renderer-document-navigation' + +describe('renderer document navigation', () => { + function createFixture(currentUrl: string, onStarted = vi.fn(() => vi.fn())) { + const handlers = new Map void>() + const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(event, handler) + }) + registerRendererDocumentNavigation({ getURL: () => currentUrl, on } as never, onStarted) + return { + navigate: handlers.get('did-start-navigation'), + failProvisionalLoad: handlers.get('did-fail-provisional-load'), + willNavigate: handlers.get('will-navigate'), + commitNavigation: handlers.get('did-frame-navigate'), + onStarted + } + } + + it('accepts the packaged renderer document but not a blocked external load', () => { + const fixture = createFixture('file:///opt/orca/renderer/index.html') + + fixture.navigate?.({}, 'https://github.com/stablyai/orca/issues', false, true) + expect(fixture.onStarted).not.toHaveBeenCalled() + fixture.navigate?.({}, 'file:///opt/orca/renderer/index.html?reload=1', false, true) + expect(fixture.onStarted).toHaveBeenCalledOnce() + }) + + it('accepts same-origin development navigation only', () => { + const fixture = createFixture('http://localhost:5173/') + + fixture.navigate?.({}, 'https://example.com/', false, true) + fixture.navigate?.({}, 'http://localhost:5173/settings', false, true) + expect(fixture.onStarted).toHaveBeenCalledOnce() + }) + + it('rejects same-document, subframe, and missing renderer navigation', () => { + const fixture = createFixture('') + + fixture.navigate?.({}, 'http://localhost:5173/', false, true) + fixture.navigate?.({}, 'http://localhost:5173/', true, true) + fixture.navigate?.({}, 'http://localhost:5173/', false, false) + expect(fixture.onStarted).not.toHaveBeenCalled() + }) + + it('cancels only the matching main-frame provisional navigation', () => { + const cancel = vi.fn() + const fixture = createFixture( + 'http://localhost:5173/', + vi.fn(() => cancel) + ) + + fixture.navigate?.({}, 'http://localhost:5173/reload', false, true) + fixture.failProvisionalLoad?.({}, -3, 'aborted', 'other', true, 1, 1) + fixture.failProvisionalLoad?.({}, -3, 'aborted', 'http://localhost:5173/reload', false, 1, 1) + expect(cancel).not.toHaveBeenCalled() + + fixture.failProvisionalLoad?.({}, -3, 'aborted', 'http://localhost:5173/reload', true, 1, 1) + expect(cancel).toHaveBeenCalledOnce() + }) + + it('shares one reload fence across concurrent provisional navigations', () => { + const cancel = vi.fn() + const onStarted = vi.fn(() => cancel) + const fixture = createFixture('http://localhost:5173/', onStarted) + + fixture.navigate?.({}, 'http://localhost:5173/reload-a', false, true) + fixture.navigate?.({}, 'http://localhost:5173/reload-b', false, true) + + expect(onStarted).toHaveBeenCalledOnce() + fixture.failProvisionalLoad?.({}, -3, 'aborted', 'http://localhost:5173/reload-a', true, 1, 1) + expect(cancel).not.toHaveBeenCalled() + fixture.failProvisionalLoad?.({}, -3, 'aborted', 'http://localhost:5173/reload-b', true, 1, 1) + expect(cancel).toHaveBeenCalledOnce() + }) + + it('does not cancel a navigation after its document commits', () => { + const cancel = vi.fn() + const fixture = createFixture( + 'file:///opt/orca/renderer/index.html', + vi.fn(() => cancel) + ) + + fixture.navigate?.({}, 'file:///opt/orca/renderer/index.html?reload=1', false, true) + fixture.commitNavigation?.( + {}, + 'file:///opt/orca/renderer/index.html?reload=1', + -1, + '', + true, + 1, + 1 + ) + fixture.failProvisionalLoad?.( + {}, + -3, + 'aborted', + 'file:///opt/orca/renderer/index.html?reload=1', + true, + 1, + 1 + ) + + expect(cancel).not.toHaveBeenCalled() + }) + + it('cancels when a later will-navigate listener blocks the navigation', async () => { + const cancel = vi.fn() + const fixture = createFixture( + 'http://localhost:5173/', + vi.fn(() => cancel) + ) + const event = { defaultPrevented: false } + + fixture.navigate?.({}, 'http://localhost:5173/reload', false, true) + fixture.willNavigate?.(event, 'http://localhost:5173/reload', false, true, 1, 1) + event.defaultPrevented = true + await Promise.resolve() + + expect(cancel).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/window/renderer-document-navigation.ts b/src/main/window/renderer-document-navigation.ts new file mode 100644 index 00000000000..1fc82a07cd8 --- /dev/null +++ b/src/main/window/renderer-document-navigation.ts @@ -0,0 +1,74 @@ +import type { WebContents } from 'electron' + +function isRendererDocumentNavigation(currentUrl: string, nextUrl: string): boolean { + try { + const current = new URL(currentUrl) + const next = new URL(nextUrl) + if (current.protocol === 'file:') { + return ( + next.protocol === 'file:' && + next.host === current.host && + next.pathname === current.pathname + ) + } + return ( + (current.protocol === 'http:' || current.protocol === 'https:') && + next.origin === current.origin + ) + } catch { + return false + } +} + +export function registerRendererDocumentNavigation( + webContents: Pick, + onStarted: () => (() => void) | void +): void { + const pendingUrls: string[] = [] + let cancelReload: (() => void) | null = null + const cancelPending = (url: string): void => { + const index = pendingUrls.indexOf(url) + if (index !== -1) { + pendingUrls.splice(index, 1) + } + if (pendingUrls.length === 0) { + const cancel = cancelReload + cancelReload = null + cancel?.() + } + } + // Why: did-start-loading also fires for blocked external links whose renderer document survives. + webContents.on('did-start-navigation', (_event, url, isSameDocument, isMainFrame) => { + if (isMainFrame && !isSameDocument && isRendererDocumentNavigation(webContents.getURL(), url)) { + if (pendingUrls.length === 0) { + cancelReload = onStarted() ?? null + } + pendingUrls.push(url) + } + }) + webContents.on( + 'did-fail-provisional-load', + (_event, _errorCode, _errorDescription, validatedUrl, isMainFrame) => { + if (!isMainFrame) { + return + } + cancelPending(validatedUrl) + } + ) + webContents.on('will-navigate', (event, url, _sameDocument, isMainFrame) => { + if (!isMainFrame) { + return + } + queueMicrotask(() => { + if (event.defaultPrevented) { + cancelPending(url) + } + }) + }) + webContents.on('did-frame-navigate', (_event, _url, _code, _status, isMainFrame) => { + if (isMainFrame) { + pendingUrls.length = 0 + cancelReload = null + } + }) +} diff --git a/src/main/window/runtime-renderer-notification-sender.test.ts b/src/main/window/runtime-renderer-notification-sender.test.ts new file mode 100644 index 00000000000..d482bb404fb --- /dev/null +++ b/src/main/window/runtime-renderer-notification-sender.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import { createRuntimeRendererNotificationSender } from './runtime-renderer-notification-sender' + +type RendererSend = (channel: string, ...args: unknown[]) => void + +function createSender( + options: { + send?: RendererSend + windowDestroyed?: boolean + webContentsDestroyed?: boolean + } = {} +) { + const send = options.send ?? vi.fn() + const onFailure = vi.fn() + const warn = vi.fn() + const sender = createRuntimeRendererNotificationSender({ + isWindowDestroyed: () => options.windowDestroyed ?? false, + webContents: { + isDestroyed: () => options.webContentsDestroyed ?? false, + send + }, + onFailure, + warn + }) + return { sender, send, onFailure, warn } +} + +describe('runtime renderer notification sender', () => { + it('contains a disposed frame and suppresses repeated sends and warnings', () => { + const failure = new Error('Render frame was disposed before WebFrameMain could be accessed') + const fixture = createSender({ + send: vi.fn(() => { + throw failure + }) + }) + + expect(fixture.sender.send('repos:changed')).toBe(false) + expect(fixture.sender.send('worktrees:changed')).toBe(false) + expect(fixture.sender.send('repos:changed')).toBe(false) + + expect(fixture.send).toHaveBeenCalledOnce() + expect(fixture.warn).toHaveBeenCalledOnce() + expect(fixture.onFailure).toHaveBeenCalledExactlyOnceWith('renderer-frame-unavailable') + }) + + it('pauses during a main-frame reload and resumes only after load finishes', () => { + const fixture = createSender() + + fixture.sender.onMainFrameReloadStarted() + expect(fixture.sender.send('repos:changed')).toBe(false) + fixture.sender.onMainFrameLoadFinished() + + expect(fixture.sender.send('repos:changed')).toBe(true) + expect(fixture.send).toHaveBeenCalledOnce() + expect(fixture.onFailure).not.toHaveBeenCalled() + }) + + it('resumes when a provisional main-frame reload is cancelled', () => { + const fixture = createSender() + + fixture.sender.onMainFrameReloadStarted() + expect(fixture.sender.send('repos:changed')).toBe(false) + fixture.sender.onMainFrameReloadCancelled() + + expect(fixture.sender.send('repos:changed')).toBe(true) + expect(fixture.send).toHaveBeenCalledOnce() + }) + + it('treats an absent or destroyed renderer as unavailable without throwing', () => { + const missingWindow = createSender({ windowDestroyed: true }) + const missingWebContents = createSender({ webContentsDestroyed: true }) + + expect(missingWindow.sender.send('repos:changed')).toBe(false) + expect(missingWebContents.sender.send('repos:changed')).toBe(false) + expect(missingWindow.onFailure).not.toHaveBeenCalled() + expect(missingWebContents.onFailure).not.toHaveBeenCalled() + }) + + it('reports renderer process loss once per load generation', () => { + const fixture = createSender() + + fixture.sender.onRendererProcessGone() + fixture.sender.onRendererProcessGone() + fixture.sender.onMainFrameReloadStarted() + fixture.sender.onMainFrameLoadFinished() + fixture.sender.onRendererProcessGone() + fixture.sender.onRendererProcessGone() + + expect(fixture.warn).toHaveBeenCalledTimes(2) + expect(fixture.onFailure).toHaveBeenCalledTimes(2) + expect(fixture.onFailure).toHaveBeenNthCalledWith(1, 'renderer-process-gone') + expect(fixture.onFailure).toHaveBeenNthCalledWith(2, 'renderer-process-gone') + }) + + it('keeps close terminal when renderer lifecycle events arrive late', () => { + const fixture = createSender() + + fixture.sender.close() + fixture.sender.onMainFrameLoadFinished() + fixture.sender.onMainFrameReloadStarted() + fixture.sender.onRendererProcessGone() + + expect(fixture.sender.send('repos:changed')).toBe(false) + expect(fixture.send).not.toHaveBeenCalled() + expect(fixture.warn).not.toHaveBeenCalled() + expect(fixture.onFailure).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/runtime-renderer-notification-sender.ts b/src/main/window/runtime-renderer-notification-sender.ts new file mode 100644 index 00000000000..e761fa5bf1d --- /dev/null +++ b/src/main/window/runtime-renderer-notification-sender.ts @@ -0,0 +1,79 @@ +export type RuntimeRendererGraphFailureReason = + | 'renderer-frame-unavailable' + | 'renderer-process-gone' + +export function createRuntimeRendererNotificationSender(args: { + isWindowDestroyed: () => boolean + webContents: { + isDestroyed: () => boolean + send: (channel: string, ...args: unknown[]) => void + } + onFailure: (reason: RuntimeRendererGraphFailureReason) => void + warn?: (message: string) => void +}): { + send: (channel: string, ...values: unknown[]) => boolean + onMainFrameReloadStarted: () => void + onMainFrameReloadCancelled: () => void + onMainFrameLoadFinished: () => void + onRendererProcessGone: () => void + close: () => void +} { + let available = true + let warningEmitted = false + let closed = false + const warn = args.warn ?? ((message: string) => console.warn(message)) + const suspend = (reason: RuntimeRendererGraphFailureReason): void => { + if (closed || (!available && warningEmitted)) { + return + } + available = false + if (!warningEmitted) { + warningEmitted = true + warn(`[runtime-graph] Renderer notifications suspended: ${reason}`) + } + args.onFailure(reason) + } + + return { + send: (channel, ...values) => { + if (closed || args.isWindowDestroyed() || args.webContents.isDestroyed() || !available) { + return false + } + try { + args.webContents.send(channel, ...values) + return true + } catch { + // Why: renderer notification is a side effect; a disposed frame must not + // fail the persistence or runtime operation that produced the event. + suspend('renderer-frame-unavailable') + return false + } + }, + onMainFrameReloadStarted: () => { + if (closed) { + return + } + available = false + warningEmitted = false + }, + onMainFrameReloadCancelled: () => { + if (closed) { + return + } + available = true + warningEmitted = false + }, + onMainFrameLoadFinished: () => { + if (closed) { + return + } + available = true + warningEmitted = false + }, + onRendererProcessGone: () => suspend('renderer-process-gone'), + close: () => { + closed = true + available = false + } + } +} diff --git a/src/main/workspace-cleanup-removal-snapshot-prune.test.ts b/src/main/workspace-cleanup-removal-snapshot-prune.test.ts new file mode 100644 index 00000000000..466476b10c3 --- /dev/null +++ b/src/main/workspace-cleanup-removal-snapshot-prune.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + finalizeCleanupSnapshotsMock, + finalizeSpaceSnapshotsMock, + pruneCleanupSnapshotsMock, + pruneSpaceSnapshotsMock, + registerCleanupTombstonesMock, + registerSpaceTombstonesMock +} = vi.hoisted(() => ({ + finalizeCleanupSnapshotsMock: vi.fn().mockResolvedValue(undefined), + finalizeSpaceSnapshotsMock: vi.fn().mockResolvedValue(undefined), + pruneCleanupSnapshotsMock: vi.fn().mockResolvedValue(undefined), + pruneSpaceSnapshotsMock: vi.fn().mockResolvedValue(undefined), + registerCleanupTombstonesMock: vi.fn(), + registerSpaceTombstonesMock: vi.fn() +})) + +vi.mock('./workspace-cleanup-scan-snapshot', () => ({ + finalizeWorkspaceCleanupScanSnapshotPrunes: finalizeCleanupSnapshotsMock, + pruneWorkspaceCleanupScanSnapshots: pruneCleanupSnapshotsMock, + registerWorkspaceCleanupScanSnapshotPruneTombstones: registerCleanupTombstonesMock +})) + +vi.mock('./workspace-space-analysis-snapshot', () => ({ + finalizeWorkspaceSpaceAnalysisSnapshotPrunes: finalizeSpaceSnapshotsMock, + pruneWorkspaceSpaceAnalysisSnapshots: pruneSpaceSnapshotsMock, + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones: registerSpaceTombstonesMock +})) + +import { + beginWorkspaceCleanupRemovalSnapshotPruneBatch, + finishWorkspaceCleanupRemovalSnapshotPruneBatch, + recordWorkspaceCleanupRemovalSnapshotPrune, + resetWorkspaceCleanupRemovalSnapshotPruneBatchesForTests +} from './workspace-cleanup-removal-snapshot-prune' + +describe('workspace cleanup removal snapshot prune batch', () => { + beforeEach(() => { + vi.clearAllMocks() + resetWorkspaceCleanupRemovalSnapshotPruneBatchesForTests() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('registers every sequential tombstone immediately and flushes once per sidecar', async () => { + const snapshotDirectory = '/profile-a' + const batch = { batchId: 'batch-1' } + const targets = Array.from({ length: 100 }, (_, index) => ({ + worktreeId: `repo-${index % 10}::/workspace-${index}`, + executionHostId: index % 2 === 0 ? ('local' as const) : (`ssh:ssh-${index % 3}` as const) + })) + beginWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, batch) + + for (const target of targets) { + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { ...batch, ...target }) + } + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { ...batch, ...targets[0] }) + + expect(registerCleanupTombstonesMock).toHaveBeenCalledTimes(100) + expect(registerSpaceTombstonesMock).toHaveBeenCalledTimes(100) + expect(finalizeCleanupSnapshotsMock).not.toHaveBeenCalled() + expect(finalizeSpaceSnapshotsMock).not.toHaveBeenCalled() + + await finishWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, batch) + + expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith(snapshotDirectory, targets) + expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith(snapshotDirectory, targets) + }) + + it('falls back to immediate best-effort pruning when no batch is active', () => { + const target = { worktreeId: 'repo-1::/workspace', executionHostId: 'runtime:vm-1' as const } + + recordWorkspaceCleanupRemovalSnapshotPrune('/profile-a', { + batchId: 'missing-batch', + ...target + }) + + expect(pruneCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target]) + expect(pruneSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target]) + }) + + it('finalizes only the matching profile and batch scope', async () => { + const batch = { batchId: 'shared-id' } + const profileATarget = { worktreeId: 'repo-a::/workspace', executionHostId: 'local' as const } + const profileBTarget = { + worktreeId: 'repo-b::/workspace', + executionHostId: 'ssh:ssh-1' as const + } + beginWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch) + beginWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-b', batch) + recordWorkspaceCleanupRemovalSnapshotPrune('/profile-a', { ...batch, ...profileATarget }) + recordWorkspaceCleanupRemovalSnapshotPrune('/profile-b', { ...batch, ...profileBTarget }) + + await finishWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch) + + expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [ + profileATarget + ]) + expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [ + profileATarget + ]) + + await finishWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-b', batch) + expect(finalizeCleanupSnapshotsMock).toHaveBeenNthCalledWith(2, '/profile-b', [profileBTarget]) + expect(finalizeSpaceSnapshotsMock).toHaveBeenNthCalledWith(2, '/profile-b', [profileBTarget]) + }) + + it('expires and finalizes an unfinished idle batch', async () => { + vi.useFakeTimers() + const batch = { batchId: 'abandoned-batch' } + const target = { worktreeId: 'repo-1::/workspace', executionHostId: 'local' as const } + beginWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch) + recordWorkspaceCleanupRemovalSnapshotPrune('/profile-a', { ...batch, ...target }) + + await vi.advanceTimersByTimeAsync(5 * 60_000) + + expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target]) + expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target]) + await finishWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch) + expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledTimes(1) + expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/workspace-cleanup-removal-snapshot-prune.ts b/src/main/workspace-cleanup-removal-snapshot-prune.ts new file mode 100644 index 00000000000..9b4244400c6 --- /dev/null +++ b/src/main/workspace-cleanup-removal-snapshot-prune.ts @@ -0,0 +1,138 @@ +import type { + WorkspaceCleanupSnapshotPruneBatchArgs, + WorkspaceCleanupSnapshotPruneRecordArgs +} from '../shared/workspace-cleanup' +import { + finalizeWorkspaceCleanupScanSnapshotPrunes, + pruneWorkspaceCleanupScanSnapshots, + registerWorkspaceCleanupScanSnapshotPruneTombstones +} from './workspace-cleanup-scan-snapshot' +import { + finalizeWorkspaceSpaceAnalysisSnapshotPrunes, + pruneWorkspaceSpaceAnalysisSnapshots, + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones +} from './workspace-space-analysis-snapshot' + +type SnapshotPruneTarget = Omit + +type SnapshotPruneBatch = { + targets: Map + expirationTimer?: ReturnType +} + +const batches = new Map() +const SNAPSHOT_PRUNE_BATCH_IDLE_TIMEOUT_MS = 5 * 60_000 + +function batchKey(snapshotDirectory: string, batchId: string): string { + return `${snapshotDirectory}\0${batchId}` +} + +function targetKey(target: SnapshotPruneTarget): string { + return `${target.executionHostId ?? '*'}\0${target.worktreeId}` +} + +function scheduleBatchExpiration( + snapshotDirectory: string, + batchId: string, + batch: SnapshotPruneBatch +): ReturnType { + const timer = setTimeout(() => { + const expired = takeBatch(snapshotDirectory, batchId, batch) + if (expired) { + void finalizeBatch(snapshotDirectory, expired) + } + }, SNAPSHOT_PRUNE_BATCH_IDLE_TIMEOUT_MS) + timer.unref() + return timer +} + +function takeBatch( + snapshotDirectory: string, + batchId: string, + expected?: SnapshotPruneBatch +): SnapshotPruneBatch | undefined { + const key = batchKey(snapshotDirectory, batchId) + const batch = batches.get(key) + if (!batch || (expected && batch !== expected)) { + return undefined + } + batches.delete(key) + if (batch.expirationTimer) { + clearTimeout(batch.expirationTimer) + } + return batch +} + +async function finalizeBatch(snapshotDirectory: string, batch: SnapshotPruneBatch): Promise { + const targets = [...batch.targets.values()] + await Promise.all([ + finalizeWorkspaceCleanupScanSnapshotPrunes(snapshotDirectory, targets), + finalizeWorkspaceSpaceAnalysisSnapshotPrunes(snapshotDirectory, targets) + ]) +} + +export function beginWorkspaceCleanupRemovalSnapshotPruneBatch( + snapshotDirectory: string, + args: WorkspaceCleanupSnapshotPruneBatchArgs +): void { + const key = batchKey(snapshotDirectory, args.batchId) + const existing = batches.get(key) + if (existing) { + if (existing.expirationTimer) { + clearTimeout(existing.expirationTimer) + } + existing.expirationTimer = scheduleBatchExpiration(snapshotDirectory, args.batchId, existing) + return + } + const batch: SnapshotPruneBatch = { targets: new Map() } + batch.expirationTimer = scheduleBatchExpiration(snapshotDirectory, args.batchId, batch) + batches.set(key, batch) +} + +/** Tombstone now; defer the two durable sidecar rewrites until the batch closes. */ +export function recordWorkspaceCleanupRemovalSnapshotPrune( + snapshotDirectory: string, + args: WorkspaceCleanupSnapshotPruneRecordArgs +): void { + const target = { + worktreeId: args.worktreeId, + ...(args.executionHostId ? { executionHostId: args.executionHostId } : {}) + } + const batch = batches.get(batchKey(snapshotDirectory, args.batchId)) + if (!batch) { + void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, [target]) + void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, [target]) + return + } + + const key = targetKey(target) + if (!batch.targets.has(key)) { + registerWorkspaceCleanupScanSnapshotPruneTombstones(snapshotDirectory, [target]) + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(snapshotDirectory, [target]) + batch.targets.set(key, target) + } + if (batch.expirationTimer) { + clearTimeout(batch.expirationTimer) + } + batch.expirationTimer = scheduleBatchExpiration(snapshotDirectory, args.batchId, batch) +} + +export async function finishWorkspaceCleanupRemovalSnapshotPruneBatch( + snapshotDirectory: string, + args: WorkspaceCleanupSnapshotPruneBatchArgs +): Promise { + const batch = takeBatch(snapshotDirectory, args.batchId) + if (!batch) { + return + } + await finalizeBatch(snapshotDirectory, batch) +} + +export function resetWorkspaceCleanupRemovalSnapshotPruneBatchesForTests(): void { + for (const batch of batches.values()) { + if (batch.expirationTimer) { + clearTimeout(batch.expirationTimer) + } + } + batches.clear() +} diff --git a/src/main/workspace-cleanup-scan-snapshot.test.ts b/src/main/workspace-cleanup-scan-snapshot.test.ts new file mode 100644 index 00000000000..ca5fa7edffe --- /dev/null +++ b/src/main/workspace-cleanup-scan-snapshot.test.ts @@ -0,0 +1,450 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WorkspaceCleanupCandidate, + WorkspaceCleanupScanResult +} from '../shared/workspace-cleanup' + +const { snapshotWriteSpy, userDataDirHolder } = vi.hoisted(() => ({ + snapshotWriteSpy: vi.fn(), + userDataDirHolder: { dir: '' } +})) + +vi.mock('./sidecar-snapshot-file', async (importOriginal) => { + const actual = await importOriginal>() + const writeSidecarSnapshot = actual.writeSidecarSnapshot as ( + file: string, + payload: unknown + ) => Promise + return { + ...actual, + writeSidecarSnapshot: async (file: string, payload: unknown) => { + snapshotWriteSpy(file, payload) + await writeSidecarSnapshot(file, payload) + } + } +}) + +import { + finalizeWorkspaceCleanupScanSnapshotPrunes, + persistWorkspaceCleanupScanResult, + pruneWorkspaceCleanupScanSnapshot, + pruneWorkspaceCleanupScanSnapshots, + readWorkspaceCleanupScanSnapshot, + registerWorkspaceCleanupScanSnapshotPruneTombstones, + workspaceCleanupScanSnapshotFingerprint +} from './workspace-cleanup-scan-snapshot' + +const SNAPSHOT_FILE = 'orca-workspace-cleanup-scan.json' +const NOW = 1_700_000_000_000 + +function makeCandidate( + overrides: Partial = {} +): WorkspaceCleanupCandidate { + return { + worktreeId: 'repo-1::/repo-feature', + repoId: 'repo-1', + repoName: 'Repo', + connectionId: null, + executionHostId: 'local', + displayName: 'Feature', + branch: 'feature', + path: '/repo-feature', + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW }, + fingerprint: '2|feature|abc123|clean|19675', + ...overrides + } +} + +function makeBroadResult(candidates: WorkspaceCleanupCandidate[]): WorkspaceCleanupScanResult { + return { scannedAt: NOW, candidates, errors: [] } +} + +describe('workspace cleanup scan snapshot', () => { + beforeEach(async () => { + snapshotWriteSpy.mockClear() + userDataDirHolder.dir = await mkdtemp(join(tmpdir(), 'orca-cleanup-snapshot-')) + }) + + afterEach(async () => { + await rm(userDataDirHolder.dir, { recursive: true, force: true }) + }) + + it('round-trips a broad scan snapshot, including SSH connectionId rows', async () => { + const sshCandidate = makeCandidate({ + worktreeId: 'repo-ssh::/remote/repo-feature', + repoId: 'repo-ssh', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1', + path: '/remote/repo-feature', + blockers: ['ssh-disconnected'], + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) + const result = makeBroadResult([makeCandidate(), sshCandidate]) + + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + result + ) + + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(result) + }) + + it('does not let an older broad scan overwrite a newer snapshot', async () => { + const newer = { + scannedAt: NOW + 1, + candidates: [makeCandidate({ displayName: 'Newer' })], + errors: [] + } + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + newer + ) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([makeCandidate({ displayName: 'Older' })]) + ) + + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(newer) + }) + + it('returns null when no snapshot has been persisted', async () => { + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + }) + + it('degrades corrupt persisted blobs to null instead of throwing', async () => { + const file = join(userDataDirHolder.dir, SNAPSHOT_FILE) + + await writeFile(file, 'not json{', 'utf-8') + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + + await writeFile( + file, + JSON.stringify({ + version: 2, + argsFingerprint: workspaceCleanupScanSnapshotFingerprint(), + result: { scannedAt: NOW, candidates: 'nope', errors: [] } + }), + 'utf-8' + ) + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + + await writeFile( + file, + JSON.stringify({ + version: 2, + argsFingerprint: workspaceCleanupScanSnapshotFingerprint(), + result: { scannedAt: NOW, candidates: [{ worktreeId: 42 }], errors: [] } + }), + 'utf-8' + ) + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + }) + + it('treats a snapshot from another classifier version as absent', async () => { + await writeFile( + join(userDataDirHolder.dir, SNAPSHOT_FILE), + JSON.stringify({ + version: 2, + argsFingerprint: 'classifier:1|includeAllWorkspaces', + result: makeBroadResult([makeCandidate()]) + }), + 'utf-8' + ) + + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + }) + + it('patches targeted rescans into the snapshot without touching other rows', async () => { + const stale = makeCandidate({ git: { ...makeCandidate().git, clean: null, checkedAt: null } }) + const other = makeCandidate({ + worktreeId: 'repo-1::/repo-other', + path: '/repo-other', + branch: 'other' + }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([stale, other]) + ) + + const rescanned = makeCandidate({ tier: 'protected', blockers: ['dirty-files'] }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { worktreeId: stale.worktreeId }, + { scannedAt: NOW + 60_000, candidates: [rescanned], errors: [] } + ) + + const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir) + expect(snapshot?.candidates).toEqual([rescanned, other]) + // Snapshot freshness stays anchored to the last FULL scan. + expect(snapshot?.scannedAt).toBe(NOW) + }) + + it('appends targeted rows the snapshot has not seen yet', async () => { + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([makeCandidate()]) + ) + + const created = makeCandidate({ worktreeId: 'repo-1::/repo-new', path: '/repo-new' }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { worktreeId: created.worktreeId }, + { scannedAt: NOW + 1, candidates: [created], errors: [] } + ) + + const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir) + expect(snapshot?.candidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo-1::/repo-feature', + 'repo-1::/repo-new' + ]) + }) + + it('does not create a snapshot from a targeted scan alone', async () => { + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { worktreeId: 'repo-1::/repo-feature' }, + makeBroadResult([makeCandidate()]) + ) + + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + await expect(readFile(join(userDataDirHolder.dir, SNAPSHOT_FILE))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('keeps the broad snapshot when a legacy suggestion-only scan completes', async () => { + const broad = makeBroadResult([ + makeCandidate(), + makeCandidate({ worktreeId: 'repo-1::/b', path: '/b' }) + ]) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + broad + ) + + const legacyRow = makeCandidate({ tier: 'review', selectedByDefault: false }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + {}, + { scannedAt: NOW + 1, candidates: [legacyRow], errors: [] } + ) + + const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir) + expect(snapshot?.candidates).toHaveLength(2) + expect(snapshot?.candidates[0]).toEqual(legacyRow) + }) + + it('prunes a removed worktree so it cannot resurrect from cache', async () => { + const kept = makeCandidate({ worktreeId: 'repo-1::/repo-kept', path: '/repo-kept' }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([makeCandidate(), kept]) + ) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/repo-feature', 'local') + + const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir) + expect(snapshot?.candidates).toEqual([kept]) + expect(snapshotWriteSpy).toHaveBeenCalledTimes(1) + + // Unknown ids are a no-op. + snapshotWriteSpy.mockClear() + await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/never-existed') + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(snapshot) + expect(snapshotWriteSpy).not.toHaveBeenCalled() + }) + + it('coalesces host-scoped local and remote prunes into one write', async () => { + const localCollision = makeCandidate() + const remoteCollision = makeCandidate({ + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + }) + const localRemoved = makeCandidate({ + worktreeId: 'repo-1::/local-removed', + path: '/local-removed' + }) + const remoteRemoved = makeCandidate({ + worktreeId: 'repo-1::/remote-removed', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1', + path: '/remote-removed' + }) + const kept = makeCandidate({ worktreeId: 'repo-1::/kept', path: '/kept' }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([localCollision, remoteCollision, localRemoved, remoteRemoved, kept]) + ) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceCleanupScanSnapshots(userDataDirHolder.dir, [ + { worktreeId: localCollision.worktreeId, executionHostId: 'local' }, + { worktreeId: localRemoved.worktreeId, executionHostId: 'local' }, + { worktreeId: remoteRemoved.worktreeId, executionHostId: 'ssh:ssh-1' } + ]) + + expect(snapshotWriteSpy).toHaveBeenCalledTimes(1) + expect(snapshotWriteSpy).toHaveBeenCalledWith( + join(userDataDirHolder.dir, SNAPSHOT_FILE), + expect.objectContaining({ + result: expect.objectContaining({ candidates: [remoteCollision, kept] }) + }) + ) + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toMatchObject({ + candidates: [remoteCollision, kept] + }) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceCleanupScanSnapshots(userDataDirHolder.dir, [ + { worktreeId: 'repo-1::/missing-local', executionHostId: 'local' }, + { worktreeId: 'repo-1::/missing-remote', executionHostId: 'ssh:ssh-1' } + ]) + expect(snapshotWriteSpy).not.toHaveBeenCalled() + }) + + it('keeps profile snapshots isolated', async () => { + const otherProfile = await mkdtemp(join(tmpdir(), 'orca-cleanup-snapshot-other-')) + try { + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([makeCandidate()]) + ) + + await expect(readWorkspaceCleanupScanSnapshot(otherProfile)).resolves.toBeNull() + } finally { + await rm(otherProfile, { recursive: true, force: true }) + } + }) + + it('does not let a scan started before a bulk removal restore pruned rows', async () => { + const local = makeCandidate() + const remote = makeCandidate({ + worktreeId: 'repo-1::/remote-feature', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1', + path: '/remote-feature' + }) + const staleResult = { + ...makeBroadResult([local, remote]), + scannedAt: Date.now() - 1 + } + await pruneWorkspaceCleanupScanSnapshots(userDataDirHolder.dir, [ + { worktreeId: local.worktreeId, executionHostId: 'local' }, + { worktreeId: remote.worktreeId, executionHostId: 'ssh:ssh-1' } + ]) + + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + staleResult + ) + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([]) + + const recreated = { ...staleResult, scannedAt: Date.now() + 1 } + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + recreated + ) + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([ + local, + remote + ]) + }) + + it('registers a tombstone immediately without rewriting the sidecar', async () => { + const staleResult = { ...makeBroadResult([makeCandidate()]), scannedAt: Date.now() - 1 } + + registerWorkspaceCleanupScanSnapshotPruneTombstones(userDataDirHolder.dir, [ + { worktreeId: 'repo-1::/repo-feature', executionHostId: 'local' } + ]) + + expect(snapshotWriteSpy).not.toHaveBeenCalled() + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + staleResult + ) + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([]) + }) + + it('keeps the original tombstone time when a removal batch flushes', async () => { + const candidate = makeCandidate() + const target = { worktreeId: candidate.worktreeId, executionHostId: 'local' as const } + const now = vi.spyOn(Date, 'now').mockReturnValue(100) + registerWorkspaceCleanupScanSnapshotPruneTombstones(userDataDirHolder.dir, [target]) + now.mockReturnValue(200) + + await finalizeWorkspaceCleanupScanSnapshotPrunes(userDataDirHolder.dir, [target]) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + { ...makeBroadResult([candidate]), scannedAt: 150 } + ) + + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([ + candidate + ]) + now.mockRestore() + }) + + it('patches and prunes host-colliding workspace ids independently', async () => { + const local = makeCandidate() + const remote = makeCandidate({ + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1', + repoName: 'Remote repo' + }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([local, remote]) + ) + + const rescannedRemote = makeCandidate({ + ...remote, + tier: 'protected', + blockers: ['dirty-files'] + }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { worktreeId: remote.worktreeId }, + { scannedAt: NOW + 1, candidates: [rescannedRemote], errors: [] } + ) + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([ + local, + rescannedRemote + ]) + + await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, remote.worktreeId, 'ssh:ssh-1') + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([ + local + ]) + }) +}) diff --git a/src/main/workspace-cleanup-scan-snapshot.ts b/src/main/workspace-cleanup-scan-snapshot.ts new file mode 100644 index 00000000000..b87020e1ffe --- /dev/null +++ b/src/main/workspace-cleanup-scan-snapshot.ts @@ -0,0 +1,313 @@ +import { + WORKSPACE_CLEANUP_CLASSIFIER_VERSION, + type WorkspaceCleanupCandidate, + type WorkspaceCleanupScanArgs, + type WorkspaceCleanupScanResult +} from '../shared/workspace-cleanup' +import { + readSidecarSnapshot, + sidecarSnapshotFile, + withSidecarSnapshotQueue, + writeSidecarSnapshot +} from './sidecar-snapshot-file' +import type { ExecutionHostId } from '../shared/execution-host' +import { + activeWorkspaceSnapshotPruneKeys, + registerWorkspaceSnapshotPrunesForFile, + workspaceSnapshotPruneKey, + workspaceSnapshotPruneTargetKeys, + type WorkspaceSnapshotPruneTarget, + type WorkspaceSnapshotPruneTombstone +} from './workspace-snapshot-prune-index' + +const SNAPSHOT_FILE_NAME = 'orca-workspace-cleanup-scan.json' +const SNAPSHOT_VERSION = 2 + +export type WorkspaceCleanupScanSnapshotPruneTarget = WorkspaceSnapshotPruneTarget + +const prunedWorkspacesByFile = new Map>() + +type PersistedWorkspaceCleanupScanSnapshot = { + version: number + argsFingerprint: string + result: WorkspaceCleanupScanResult +} + +/** Why a fingerprint: a classifier bump reshuffles tiers/blockers wholesale, so an older snapshot must read as absent, not stale-but-plausible. */ +export function workspaceCleanupScanSnapshotFingerprint(): string { + return `classifier:${WORKSPACE_CLEANUP_CLASSIFIER_VERSION}|includeAllWorkspaces` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isPersistableCandidate(value: unknown): value is WorkspaceCleanupCandidate { + if (!isRecord(value)) { + return false + } + return ( + typeof value.worktreeId === 'string' && + typeof value.repoId === 'string' && + typeof value.fingerprint === 'string' && + (value.connectionId === null || typeof value.connectionId === 'string') && + typeof value.executionHostId === 'string' && + Array.isArray(value.reasons) && + Array.isArray(value.blockers) && + isRecord(value.git) && + isRecord(value.localContext) + ) +} + +/** Shape guard so a corrupt persisted blob degrades to null instead of throwing at startup. */ +function parseSnapshot(parsed: unknown): WorkspaceCleanupScanResult | null { + if (!isRecord(parsed)) { + return null + } + if (parsed.version !== SNAPSHOT_VERSION) { + return null + } + if (parsed.argsFingerprint !== workspaceCleanupScanSnapshotFingerprint()) { + return null + } + const result = parsed.result + if (!isRecord(result)) { + return null + } + if ( + typeof result.scannedAt !== 'number' || + !Array.isArray(result.candidates) || + !Array.isArray(result.errors) || + !result.candidates.every(isPersistableCandidate) + ) { + return null + } + return result as unknown as WorkspaceCleanupScanResult +} + +export async function readWorkspaceCleanupScanSnapshot( + snapshotDirectory: string +): Promise { + try { + return parseSnapshot( + await readSidecarSnapshot(sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)) + ) + } catch { + return null + } +} + +async function writeSnapshot(file: string, result: WorkspaceCleanupScanResult): Promise { + await writeSidecarSnapshot(file, { + version: SNAPSHOT_VERSION, + argsFingerprint: workspaceCleanupScanSnapshotFingerprint(), + result + } satisfies PersistedWorkspaceCleanupScanSnapshot) +} + +function patchCandidates( + existing: WorkspaceCleanupScanResult, + fresh: WorkspaceCleanupCandidate[] +): WorkspaceCleanupScanResult { + const freshById = new Map(fresh.map((candidate) => [candidateSnapshotKey(candidate), candidate])) + const candidates = existing.candidates.map((candidate) => { + const key = candidateSnapshotKey(candidate) + const replacement = freshById.get(key) + freshById.delete(key) + return replacement ?? candidate + }) + candidates.push(...freshById.values()) + // Why keep scannedAt: it marks the last FULL scan; a focused rescan must not advertise fleet-wide freshness. + return { ...existing, candidates } +} + +function candidateSnapshotKey( + candidate: Pick +): string { + return `${candidate.executionHostId ?? 'local'}\0${candidate.worktreeId}` +} + +/** Register anti-resurrection tombstones without scheduling a sidecar rewrite. */ +export function registerWorkspaceCleanupScanSnapshotPruneTombstones( + snapshotDirectory: string, + targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[] +): void { + if (targets.length === 0) { + return + } + registerWorkspaceSnapshotPrunesForFile( + prunedWorkspacesByFile, + sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME), + targets + ) +} + +function excludeRowsPrunedDuringScan( + file: string, + result: WorkspaceCleanupScanResult +): WorkspaceCleanupScanResult { + const prunedKeys = activeWorkspaceSnapshotPruneKeys( + prunedWorkspacesByFile.get(file), + result.scannedAt + ) + if (prunedKeys.size === 0) { + return result + } + const candidates = result.candidates.filter( + (candidate) => + !prunedKeys.has(workspaceSnapshotPruneKey(candidate.worktreeId, candidate.executionHostId)) && + !prunedKeys.has(workspaceSnapshotPruneKey(candidate.worktreeId)) + ) + return candidates.length === result.candidates.length ? result : { ...result, candidates } +} + +function clearSupersededPrunes( + file: string, + result: WorkspaceCleanupScanResult, + broad: boolean +): void { + const pruned = prunedWorkspacesByFile.get(file) + if (!pruned) { + return + } + const candidateKeys = broad + ? undefined + : new Set( + result.candidates.flatMap((candidate) => [ + workspaceSnapshotPruneKey(candidate.worktreeId, candidate.executionHostId), + workspaceSnapshotPruneKey(candidate.worktreeId) + ]) + ) + for (const [key, entry] of pruned) { + if (entry.prunedAt < result.scannedAt && (broad || candidateKeys?.has(key))) { + pruned.delete(key) + } + } + if (pruned.size === 0) { + prunedWorkspacesByFile.delete(file) + } +} + +/** + * Persist a completed scan: a broad (includeAllWorkspaces) scan replaces the snapshot, anything + * narrower patches matching rows into it. Never throws — the snapshot is a refetchable cache. + */ +// Why: skip the full snapshot read whose only purpose is the scannedAt +// comparison — on a large fleet that read is a multi-hundred-KB synchronous +// JSON.parse per scan. +const lastPersistedScannedAtByFile = new Map() + +export async function persistWorkspaceCleanupScanResult( + snapshotDirectory: string, + args: WorkspaceCleanupScanArgs, + result: WorkspaceCleanupScanResult +): Promise { + const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME) + try { + await withSidecarSnapshotQueue(file, async () => { + const filteredResult = excludeRowsPrunedDuringScan(file, result) + // worktreeIds (even empty) is a targeted scan; persisting it as broad + // would replace the fleet snapshot with a subset. + const broad = + !args.worktreeId && !Array.isArray(args.worktreeIds) && args.includeAllWorkspaces === true + if (broad) { + let knownScannedAt = lastPersistedScannedAtByFile.get(file) + if (knownScannedAt === undefined) { + const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory) + knownScannedAt = existing?.scannedAt + } + if (knownScannedAt !== undefined && knownScannedAt > filteredResult.scannedAt) { + lastPersistedScannedAtByFile.set(file, knownScannedAt) + return + } + await writeSnapshot(file, filteredResult) + lastPersistedScannedAtByFile.set(file, filteredResult.scannedAt) + clearSupersededPrunes(file, result, true) + return + } + if (filteredResult.candidates.length === 0) { + return + } + const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory) + // Why: a focused/legacy scan is a subset; without a broad baseline it is not a fleet snapshot. + if (!existing) { + return + } + await writeSnapshot(file, patchCandidates(existing, filteredResult.candidates)) + clearSupersededPrunes(file, result, false) + }) + } catch (error) { + console.warn('[workspace-cleanup] failed to persist scan snapshot:', error) + } +} + +async function pruneWorkspaceCleanupScanSnapshotsWithRegisteredTombstones( + snapshotDirectory: string, + targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[], + registerTombstones: boolean +): Promise { + if (targets.length === 0) { + return + } + const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME) + const targetKeys = workspaceSnapshotPruneTargetKeys(targets) + if (registerTombstones) { + registerWorkspaceSnapshotPrunesForFile(prunedWorkspacesByFile, file, targets) + } + try { + await withSidecarSnapshotQueue(file, async () => { + const registered = prunedWorkspacesByFile.get(file) + const coalescedTargetKeys = registerTombstones + ? targetKeys + : new Set([...targetKeys].filter((key) => registered?.has(key))) + if (coalescedTargetKeys.size === 0) { + return + } + const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory) + if (!existing) { + return + } + const candidates = existing.candidates.filter( + (candidate) => + !coalescedTargetKeys.has( + workspaceSnapshotPruneKey(candidate.worktreeId, candidate.executionHostId) + ) && !coalescedTargetKeys.has(workspaceSnapshotPruneKey(candidate.worktreeId)) + ) + if (candidates.length === existing.candidates.length) { + return + } + await writeSnapshot(file, { ...existing, candidates }) + }) + } catch (error) { + console.warn('[workspace-cleanup] failed to prune scan snapshot:', error) + } +} + +/** Drop removed workspaces in one sidecar transaction. Never throws. */ +export async function pruneWorkspaceCleanupScanSnapshots( + snapshotDirectory: string, + targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[] +): Promise { + await pruneWorkspaceCleanupScanSnapshotsWithRegisteredTombstones(snapshotDirectory, targets, true) +} + +/** Flush only tombstones still active for this batch, preserving their original prune time. */ +export async function finalizeWorkspaceCleanupScanSnapshotPrunes( + snapshotDirectory: string, + targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[] +): Promise { + await pruneWorkspaceCleanupScanSnapshotsWithRegisteredTombstones( + snapshotDirectory, + targets, + false + ) +} + +/** Drop a removed workspace so it never resurrects from cache. Never throws. */ +export async function pruneWorkspaceCleanupScanSnapshot( + snapshotDirectory: string, + worktreeId: string, + executionHostId?: ExecutionHostId +): Promise { + await pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, [{ worktreeId, executionHostId }]) +} diff --git a/src/main/workspace-snapshot-prune-index.ts b/src/main/workspace-snapshot-prune-index.ts new file mode 100644 index 00000000000..56b4289335b --- /dev/null +++ b/src/main/workspace-snapshot-prune-index.ts @@ -0,0 +1,57 @@ +import type { ExecutionHostId } from '../shared/execution-host' + +export type WorkspaceSnapshotPruneTarget = { + worktreeId: string + executionHostId?: ExecutionHostId +} + +export type WorkspaceSnapshotPruneTombstone = WorkspaceSnapshotPruneTarget & { + prunedAt: number +} + +export function workspaceSnapshotPruneKey( + worktreeId: string, + executionHostId?: ExecutionHostId +): string { + return `${executionHostId ?? '*'}\0${worktreeId}` +} + +export function workspaceSnapshotPruneTargetKeys( + targets: readonly WorkspaceSnapshotPruneTarget[] +): Set { + return new Set( + targets.map(({ worktreeId, executionHostId }) => + workspaceSnapshotPruneKey(worktreeId, executionHostId) + ) + ) +} + +export function activeWorkspaceSnapshotPruneKeys( + tombstones: ReadonlyMap | undefined, + scannedAt: number +): Set { + const keys = new Set() + for (const [key, entry] of tombstones ?? []) { + if (entry.prunedAt >= scannedAt) { + keys.add(key) + } + } + return keys +} + +export function registerWorkspaceSnapshotPrunesForFile( + tombstonesByFile: Map>, + file: string, + targets: readonly WorkspaceSnapshotPruneTarget[] +): void { + const tombstones = tombstonesByFile.get(file) ?? new Map() + const prunedAt = Date.now() + for (const { worktreeId, executionHostId } of targets) { + tombstones.set(workspaceSnapshotPruneKey(worktreeId, executionHostId), { + worktreeId, + ...(executionHostId ? { executionHostId } : {}), + prunedAt + }) + } + tombstonesByFile.set(file, tombstones) +} diff --git a/src/main/workspace-snapshot-pruning.bench.test.ts b/src/main/workspace-snapshot-pruning.bench.test.ts new file mode 100644 index 00000000000..0523894265b --- /dev/null +++ b/src/main/workspace-snapshot-pruning.bench.test.ts @@ -0,0 +1,153 @@ +// Opt in: ORCA_WORKSPACE_SNAPSHOT_PRUNE_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/workspace-snapshot-pruning.bench.test.ts +import { performance } from 'node:perf_hooks' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { WorkspaceCleanupCandidate } from '../shared/workspace-cleanup' +import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types' +import { + persistWorkspaceCleanupScanResult, + pruneWorkspaceCleanupScanSnapshots +} from './workspace-cleanup-scan-snapshot' +import { + persistWorkspaceSpaceAnalysisSnapshot, + pruneWorkspaceSpaceAnalysisSnapshots +} from './workspace-space-analysis-snapshot' + +const describeBench = process.env.ORCA_WORKSPACE_SNAPSHOT_PRUNE_BENCH ? describe : describe.skip +const ROW_COUNT = 610 +const DELETE_COUNT = 100 + +describeBench('workspace snapshot bulk pruning', () => { + let snapshotDirectory = '' + + beforeAll(async () => { + snapshotDirectory = await mkdtemp(join(tmpdir(), 'orca-snapshot-prune-bench-')) + }) + + afterAll(async () => { + await rm(snapshotDirectory, { recursive: true, force: true }) + }) + + it('prunes the measured 610-row/100-delete shape with one write per sidecar', async () => { + const candidates = Array.from({ length: ROW_COUNT }, (_, index) => makeCandidate(index)) + const worktrees = Array.from({ length: ROW_COUNT }, (_, index) => makeWorktree(index)) + const targets = candidates.slice(0, DELETE_COUNT).map((candidate) => ({ + worktreeId: candidate.worktreeId, + executionHostId: candidate.executionHostId + })) + await persistWorkspaceCleanupScanResult( + snapshotDirectory, + { includeAllWorkspaces: true }, + { scannedAt: 1, candidates, errors: [] } + ) + await persistWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, { + scannedAt: 1, + totalSizeBytes: ROW_COUNT * 1_000, + reclaimableBytes: ROW_COUNT * 1_000, + worktreeCount: ROW_COUNT, + scannedWorktreeCount: ROW_COUNT, + unavailableWorktreeCount: 0, + repos: makeRepoSummaries(worktrees), + worktrees + }) + + const startedAt = performance.now() + await Promise.all([ + pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets), + pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) + ]) + const pruneMs = performance.now() - startedAt + const cleanupPayload = JSON.parse( + await readFile(join(snapshotDirectory, 'orca-workspace-cleanup-scan.json'), 'utf-8') + ) as { result: { candidates: unknown[] } } + const spacePayload = JSON.parse( + await readFile(join(snapshotDirectory, 'orca-workspace-space-analysis.json'), 'utf-8') + ) as { analysis: { worktrees: unknown[] } } + + console.log( + `[bench] rows=${ROW_COUNT} deleted=${DELETE_COUNT} bulkPrune=${pruneMs.toFixed(2)}ms` + ) + expect(cleanupPayload.result.candidates).toHaveLength(ROW_COUNT - DELETE_COUNT) + expect(spacePayload.analysis.worktrees).toHaveLength(ROW_COUNT - DELETE_COUNT) + }) +}) + +function executionHostId(index: number): 'local' | `ssh:${string}` { + return index % 3 === 0 ? `ssh:ssh-${index % 7}` : 'local' +} + +function makeCandidate(index: number): WorkspaceCleanupCandidate { + const hostId = executionHostId(index) + return { + worktreeId: `repo-${index % 20}::/workspace-${index}`, + repoId: `repo-${index % 20}`, + repoName: `Repo ${index % 20}`, + connectionId: hostId === 'local' ? null : hostId.slice('ssh:'.length), + executionHostId: hostId, + displayName: `Workspace ${index}`, + branch: `branch-${index}`, + path: `/workspace-${index}`, + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: 0, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 0 }, + fingerprint: `fingerprint-${index}` + } +} + +function makeWorktree(index: number): WorkspaceSpaceWorktree { + const candidate = makeCandidate(index) + return { + worktreeId: candidate.worktreeId, + repoId: candidate.repoId, + executionHostId: candidate.executionHostId, + repoDisplayName: candidate.repoName, + repoPath: `/repo-${index % 20}`, + displayName: candidate.displayName, + path: candidate.path, + branch: candidate.branch, + isMainWorktree: false, + isRemote: candidate.executionHostId !== 'local', + isSparse: false, + canDelete: true, + lastActivityAt: 0, + status: 'ok', + error: null, + scannedAt: 1, + sizeBytes: 1_000, + reclaimableBytes: 1_000, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } +} + +function makeRepoSummaries(worktrees: WorkspaceSpaceWorktree[]) { + const groups = Map.groupBy(worktrees, (row) => `${row.executionHostId ?? 'local'}\0${row.repoId}`) + return [...groups.values()].map((rows) => ({ + repoId: rows[0].repoId, + executionHostId: rows[0].executionHostId, + displayName: rows[0].repoDisplayName, + path: rows[0].repoPath, + isRemote: rows[0].isRemote, + worktreeCount: rows.length, + scannedWorktreeCount: rows.length, + unavailableWorktreeCount: 0, + totalSizeBytes: rows.length * 1_000, + reclaimableBytes: rows.length * 1_000, + error: null + })) +} diff --git a/src/main/workspace-space-analysis-snapshot.test.ts b/src/main/workspace-space-analysis-snapshot.test.ts new file mode 100644 index 00000000000..a6d870674e3 --- /dev/null +++ b/src/main/workspace-space-analysis-snapshot.test.ts @@ -0,0 +1,438 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WorkspaceSpaceAnalysis, + WorkspaceSpaceWorktree +} from '../shared/workspace-space-types' + +const { snapshotWriteSpy, userDataDirHolder } = vi.hoisted(() => ({ + snapshotWriteSpy: vi.fn(), + userDataDirHolder: { dir: '' } +})) + +vi.mock('./sidecar-snapshot-file', async (importOriginal) => { + const actual = await importOriginal>() + const writeSidecarSnapshot = actual.writeSidecarSnapshot as ( + file: string, + payload: unknown + ) => Promise + return { + ...actual, + writeSidecarSnapshot: async (file: string, payload: unknown) => { + snapshotWriteSpy(file, payload) + await writeSidecarSnapshot(file, payload) + } + } +}) + +import { + finalizeWorkspaceSpaceAnalysisSnapshotPrunes, + persistWorkspaceSpaceAnalysisSnapshot, + pruneWorkspaceSpaceAnalysisSnapshot, + pruneWorkspaceSpaceAnalysisSnapshots, + readWorkspaceSpaceAnalysisSnapshot, + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones +} from './workspace-space-analysis-snapshot' + +const SNAPSHOT_FILE = 'orca-workspace-space-analysis.json' +const NOW = 1_700_000_000_000 + +function makeWorktreeRow(overrides: Partial = {}): WorkspaceSpaceWorktree { + return { + worktreeId: 'repo-1::/repo-feature', + repoId: 'repo-1', + executionHostId: 'local', + repoDisplayName: 'Repo', + repoPath: '/repo', + displayName: 'Feature', + path: '/repo-feature', + branch: 'feature', + isMainWorktree: false, + isRemote: false, + isSparse: false, + canDelete: true, + lastActivityAt: NOW - 1000, + status: 'ok', + error: null, + scannedAt: NOW, + sizeBytes: 1000, + reclaimableBytes: 1000, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0, + ...overrides + } +} + +function makeAnalysis(worktrees: WorkspaceSpaceWorktree[]): WorkspaceSpaceAnalysis { + const okRows = worktrees.filter((row) => row.status === 'ok') + const rowsByHost = Map.groupBy(worktrees, (row) => row.executionHostId ?? 'local') + return { + scannedAt: NOW, + totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0), + reclaimableBytes: worktrees.reduce((sum, row) => sum + row.reclaimableBytes, 0), + worktreeCount: worktrees.length, + scannedWorktreeCount: okRows.length, + unavailableWorktreeCount: worktrees.length - okRows.length, + repos: [...rowsByHost.entries()].map(([executionHostId, rows]) => { + const scanned = rows.filter((row) => row.status === 'ok') + return { + repoId: 'repo-1', + executionHostId, + displayName: 'Repo', + path: '/repo', + isRemote: executionHostId !== 'local', + worktreeCount: rows.length, + scannedWorktreeCount: scanned.length, + unavailableWorktreeCount: rows.length - scanned.length, + totalSizeBytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0), + reclaimableBytes: rows.reduce((sum, row) => sum + row.reclaimableBytes, 0), + error: null + } + }), + worktrees + } +} + +describe('workspace space analysis snapshot', () => { + beforeEach(async () => { + snapshotWriteSpy.mockClear() + userDataDirHolder.dir = await mkdtemp(join(tmpdir(), 'orca-space-snapshot-')) + }) + + afterEach(async () => { + await rm(userDataDirHolder.dir, { recursive: true, force: true }) + }) + + it('round-trips a completed analysis', async () => { + const analysis = makeAnalysis([makeWorktreeRow()]) + + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, analysis) + + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toEqual( + analysis + ) + }) + + it('prunes topLevelItems into the omitted counters to bound the payload', async () => { + const row = makeWorktreeRow({ + sizeBytes: 5000, + topLevelItems: [ + { + name: 'node_modules', + path: '/repo-feature/node_modules', + kind: 'directory', + sizeBytes: 3000 + }, + { name: 'src', path: '/repo-feature/src', kind: 'directory', sizeBytes: 1000 } + ], + omittedTopLevelItemCount: 2, + omittedTopLevelSizeBytes: 500 + }) + + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, makeAnalysis([row])) + + const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) + expect(cached?.worktrees[0]).toMatchObject({ + sizeBytes: 5000, + topLevelItems: [], + omittedTopLevelItemCount: 4, + omittedTopLevelSizeBytes: 4500 + }) + }) + + it('returns null when missing or corrupt instead of throwing', async () => { + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + + const file = join(userDataDirHolder.dir, SNAPSHOT_FILE) + await writeFile(file, '{"version":1,"analysis":', 'utf-8') + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + + await writeFile( + file, + JSON.stringify({ + version: 2, + analysis: { scannedAt: 'yesterday', repos: [], worktrees: [] } + }), + 'utf-8' + ) + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + + await writeFile( + file, + JSON.stringify({ version: 99, analysis: makeAnalysis([makeWorktreeRow()]) }), + 'utf-8' + ) + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull() + }) + + it('loads legacy rows without an execution host id', async () => { + const { executionHostId: _executionHostId, ...legacyRow } = makeWorktreeRow() + const analysis = makeAnalysis([makeWorktreeRow()]) + await writeFile( + join(userDataDirHolder.dir, SNAPSHOT_FILE), + JSON.stringify({ + version: 2, + analysis: { ...analysis, worktrees: [legacyRow] } + }), + 'utf-8' + ) + + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toMatchObject({ + worktrees: [expect.objectContaining({ worktreeId: legacyRow.worktreeId })] + }) + }) + + it('prunes a removed worktree row and rebalances totals', async () => { + const removed = makeWorktreeRow({ sizeBytes: 3000, reclaimableBytes: 3000 }) + const kept = makeWorktreeRow({ + worktreeId: 'repo-1::/repo-kept', + path: '/repo-kept', + status: 'missing', + sizeBytes: 1000, + reclaimableBytes: 0 + }) + await persistWorkspaceSpaceAnalysisSnapshot( + userDataDirHolder.dir, + makeAnalysis([removed, kept]) + ) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, removed.worktreeId, 'local') + + const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) + expect(cached?.worktrees.map((row) => row.worktreeId)).toEqual(['repo-1::/repo-kept']) + expect(cached).toMatchObject({ + worktreeCount: 1, + scannedWorktreeCount: 0, + unavailableWorktreeCount: 1, + totalSizeBytes: 1000, + reclaimableBytes: 0 + }) + expect(cached?.repos[0]).toMatchObject({ + worktreeCount: 1, + scannedWorktreeCount: 0, + unavailableWorktreeCount: 1, + totalSizeBytes: 1000, + reclaimableBytes: 0 + }) + expect(snapshotWriteSpy).toHaveBeenCalledTimes(1) + + // Unknown ids are a no-op. + snapshotWriteSpy.mockClear() + await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, 'repo-1::/never-existed') + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toEqual(cached) + expect(snapshotWriteSpy).not.toHaveBeenCalled() + }) + + it('coalesces local and remote row rebalancing into one write', async () => { + const localCollision = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 }) + const remoteCollision = makeWorktreeRow({ + executionHostId: 'ssh:ssh-1', + isRemote: true, + sizeBytes: 2000, + reclaimableBytes: 2000 + }) + const localRemoved = makeWorktreeRow({ + worktreeId: 'repo-1::/local-removed', + path: '/local-removed', + status: 'missing', + sizeBytes: 3000, + reclaimableBytes: 0 + }) + const remoteRemoved = makeWorktreeRow({ + worktreeId: 'repo-1::/remote-removed', + executionHostId: 'ssh:ssh-1', + isRemote: true, + path: '/remote-removed', + sizeBytes: 4000, + reclaimableBytes: 4000 + }) + const kept = makeWorktreeRow({ + worktreeId: 'repo-1::/kept', + path: '/kept', + sizeBytes: 500, + reclaimableBytes: 500 + }) + await persistWorkspaceSpaceAnalysisSnapshot( + userDataDirHolder.dir, + makeAnalysis([localCollision, remoteCollision, localRemoved, remoteRemoved, kept]) + ) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceSpaceAnalysisSnapshots(userDataDirHolder.dir, [ + { worktreeId: localCollision.worktreeId, executionHostId: 'local' }, + { worktreeId: localRemoved.worktreeId, executionHostId: 'local' }, + { worktreeId: remoteRemoved.worktreeId, executionHostId: 'ssh:ssh-1' } + ]) + + expect(snapshotWriteSpy).toHaveBeenCalledTimes(1) + expect(snapshotWriteSpy).toHaveBeenCalledWith( + join(userDataDirHolder.dir, SNAPSHOT_FILE), + expect.objectContaining({ + analysis: expect.objectContaining({ + worktrees: [remoteCollision, kept], + worktreeCount: 2, + scannedWorktreeCount: 2, + unavailableWorktreeCount: 0, + totalSizeBytes: 2500, + reclaimableBytes: 2500 + }) + }) + ) + const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) + expect(cached?.repos).toEqual([ + expect.objectContaining({ executionHostId: 'local', worktreeCount: 1, totalSizeBytes: 500 }), + expect.objectContaining({ + executionHostId: 'ssh:ssh-1', + worktreeCount: 1, + totalSizeBytes: 2000 + }) + ]) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceSpaceAnalysisSnapshots(userDataDirHolder.dir, [ + { worktreeId: 'repo-1::/missing-local', executionHostId: 'local' }, + { worktreeId: 'repo-1::/missing-remote', executionHostId: 'ssh:ssh-1' } + ]) + expect(snapshotWriteSpy).not.toHaveBeenCalled() + }) + + it('keeps profile snapshots isolated', async () => { + const otherProfile = await mkdtemp(join(tmpdir(), 'orca-space-snapshot-other-')) + try { + await persistWorkspaceSpaceAnalysisSnapshot( + userDataDirHolder.dir, + makeAnalysis([makeWorktreeRow()]) + ) + + await expect(readWorkspaceSpaceAnalysisSnapshot(otherProfile)).resolves.toBeNull() + } finally { + await rm(otherProfile, { recursive: true, force: true }) + } + }) + + it('does not let an analysis started before bulk removal restore pruned rows', async () => { + const local = makeWorktreeRow() + const remote = makeWorktreeRow({ + worktreeId: 'repo-1::/remote-feature', + executionHostId: 'ssh:ssh-1', + isRemote: true, + path: '/remote-feature' + }) + const staleAnalysis = { + ...makeAnalysis([local, remote]), + scannedAt: Date.now() - 1 + } + await pruneWorkspaceSpaceAnalysisSnapshots(userDataDirHolder.dir, [ + { worktreeId: local.worktreeId, executionHostId: 'local' }, + { worktreeId: remote.worktreeId, executionHostId: 'ssh:ssh-1' } + ]) + + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, staleAnalysis) + expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([]) + + const recreated = { ...staleAnalysis, scannedAt: Date.now() + 1 } + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, recreated) + expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([ + local, + remote + ]) + }) + + it('registers a tombstone immediately without rewriting the sidecar', async () => { + const staleAnalysis = { ...makeAnalysis([makeWorktreeRow()]), scannedAt: Date.now() - 1 } + + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(userDataDirHolder.dir, [ + { worktreeId: 'repo-1::/repo-feature', executionHostId: 'local' } + ]) + + expect(snapshotWriteSpy).not.toHaveBeenCalled() + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, staleAnalysis) + expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([]) + }) + + it('keeps the original tombstone time when a removal batch flushes', async () => { + const row = makeWorktreeRow() + const target = { worktreeId: row.worktreeId, executionHostId: 'local' as const } + const now = vi.spyOn(Date, 'now').mockReturnValue(100) + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(userDataDirHolder.dir, [target]) + now.mockReturnValue(200) + + await finalizeWorkspaceSpaceAnalysisSnapshotPrunes(userDataDirHolder.dir, [target]) + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, { + ...makeAnalysis([row]), + scannedAt: 150 + }) + + expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([ + { ...row, topLevelItems: [] } + ]) + now.mockRestore() + }) + + it('prunes host-colliding workspace ids without corrupting surviving totals', async () => { + const local = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 }) + const remote = makeWorktreeRow({ + executionHostId: 'ssh:ssh-1', + isRemote: true, + sizeBytes: 3000, + reclaimableBytes: 3000 + }) + await persistWorkspaceSpaceAnalysisSnapshot( + userDataDirHolder.dir, + makeAnalysis([local, remote]) + ) + + await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, remote.worktreeId, 'ssh:ssh-1') + + const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) + expect(cached?.worktrees).toEqual([local]) + expect(cached).toMatchObject({ + worktreeCount: 1, + scannedWorktreeCount: 1, + totalSizeBytes: 1000, + reclaimableBytes: 1000 + }) + expect(cached?.repos).toEqual([ + expect.objectContaining({ + executionHostId: 'local', + worktreeCount: 1, + totalSizeBytes: 1000 + }), + expect.objectContaining({ + executionHostId: 'ssh:ssh-1', + worktreeCount: 0, + totalSizeBytes: 0 + }) + ]) + }) + + it('prunes every host-colliding row when the host is unknown', async () => { + const local = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 }) + const remote = makeWorktreeRow({ + executionHostId: 'ssh:ssh-1', + isRemote: true, + sizeBytes: 3000, + reclaimableBytes: 3000 + }) + await persistWorkspaceSpaceAnalysisSnapshot( + userDataDirHolder.dir, + makeAnalysis([local, remote]) + ) + + await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, local.worktreeId) + + const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) + expect(cached?.worktrees).toEqual([]) + expect(cached).toMatchObject({ + worktreeCount: 0, + scannedWorktreeCount: 0, + totalSizeBytes: 0, + reclaimableBytes: 0 + }) + }) +}) diff --git a/src/main/workspace-space-analysis-snapshot.ts b/src/main/workspace-space-analysis-snapshot.ts new file mode 100644 index 00000000000..8118095d396 --- /dev/null +++ b/src/main/workspace-space-analysis-snapshot.ts @@ -0,0 +1,328 @@ +import type { + WorkspaceSpaceAnalysis, + WorkspaceSpaceWorktree +} from '../shared/workspace-space-types' +import { + readSidecarSnapshot, + sidecarSnapshotFile, + withSidecarSnapshotQueue, + writeSidecarSnapshot +} from './sidecar-snapshot-file' +import type { ExecutionHostId } from '../shared/execution-host' +import { + activeWorkspaceSnapshotPruneKeys, + registerWorkspaceSnapshotPrunesForFile, + workspaceSnapshotPruneKey, + workspaceSnapshotPruneTargetKeys, + type WorkspaceSnapshotPruneTarget, + type WorkspaceSnapshotPruneTombstone +} from './workspace-snapshot-prune-index' + +const SNAPSHOT_FILE_NAME = 'orca-workspace-space-analysis.json' +const SNAPSHOT_VERSION = 2 + +export type WorkspaceSpaceAnalysisSnapshotPruneTarget = WorkspaceSnapshotPruneTarget + +const prunedWorkspacesByFile = new Map>() + +type PersistedWorkspaceSpaceAnalysisSnapshot = { + version: number + analysis: WorkspaceSpaceAnalysis +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isPersistableWorktreeRow(value: unknown): value is WorkspaceSpaceWorktree { + if (!isRecord(value)) { + return false + } + return ( + typeof value.worktreeId === 'string' && + typeof value.repoId === 'string' && + (value.executionHostId === undefined || typeof value.executionHostId === 'string') && + typeof value.status === 'string' && + typeof value.sizeBytes === 'number' && + typeof value.reclaimableBytes === 'number' && + Array.isArray(value.topLevelItems) + ) +} + +/** Shape guard so a corrupt persisted blob degrades to null instead of throwing at startup. */ +function parseSnapshot(parsed: unknown): WorkspaceSpaceAnalysis | null { + if (!isRecord(parsed) || parsed.version !== SNAPSHOT_VERSION) { + return null + } + const analysis = parsed.analysis + if (!isRecord(analysis)) { + return null + } + if ( + typeof analysis.scannedAt !== 'number' || + typeof analysis.totalSizeBytes !== 'number' || + !Array.isArray(analysis.repos) || + !Array.isArray(analysis.worktrees) || + !analysis.worktrees.every(isPersistableWorktreeRow) + ) { + return null + } + return analysis as unknown as WorkspaceSpaceAnalysis +} + +// Why strip topLevelItems: 500+ worktrees x 48 items is a multi-MB blob, and the cached view only +// needs per-worktree totals. Fold the items into the omitted counters so each row stays consistent. +function stripTopLevelItems(analysis: WorkspaceSpaceAnalysis): WorkspaceSpaceAnalysis { + return { + ...analysis, + worktrees: analysis.worktrees.map((row) => ({ + ...row, + topLevelItems: [], + omittedTopLevelItemCount: row.omittedTopLevelItemCount + row.topLevelItems.length, + omittedTopLevelSizeBytes: + row.omittedTopLevelSizeBytes + + row.topLevelItems.reduce((sum, item) => sum + item.sizeBytes, 0) + })) + } +} + +export async function readWorkspaceSpaceAnalysisSnapshot( + snapshotDirectory: string +): Promise { + try { + return parseSnapshot( + await readSidecarSnapshot(sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)) + ) + } catch { + return null + } +} + +async function writeSnapshot(file: string, analysis: WorkspaceSpaceAnalysis): Promise { + await writeSidecarSnapshot(file, { + version: SNAPSHOT_VERSION, + analysis + } satisfies PersistedWorkspaceSpaceAnalysisSnapshot) +} + +/** Persist a completed analysis. Never throws — the snapshot is a refetchable cache. */ +export async function persistWorkspaceSpaceAnalysisSnapshot( + snapshotDirectory: string, + analysis: WorkspaceSpaceAnalysis +): Promise { + const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME) + try { + await withSidecarSnapshotQueue(file, async () => { + await writeSnapshot(file, stripTopLevelItems(excludeRowsPrunedDuringScan(file, analysis))) + clearSupersededPrunes(file, analysis) + }) + } catch (error) { + console.warn('[workspace-space] failed to persist analysis snapshot:', error) + } +} + +function withoutWorktreeRows( + analysis: WorkspaceSpaceAnalysis, + shouldRemove: (row: WorkspaceSpaceWorktree) => boolean +): WorkspaceSpaceAnalysis { + const worktrees: WorkspaceSpaceWorktree[] = [] + const removedByRepo = new Map< + string, + { + worktreeCount: number + scannedWorktreeCount: number + unavailableWorktreeCount: number + totalSizeBytes: number + reclaimableBytes: number + } + >() + let removedCount = 0 + let scannedDelta = 0 + let unavailableDelta = 0 + let totalSizeDelta = 0 + let reclaimableDelta = 0 + + for (const row of analysis.worktrees) { + if (!shouldRemove(row)) { + worktrees.push(row) + continue + } + const scanned = row.status === 'ok' ? 1 : 0 + const unavailable = row.status === 'ok' ? 0 : 1 + removedCount += 1 + scannedDelta += scanned + unavailableDelta += unavailable + totalSizeDelta += row.sizeBytes + reclaimableDelta += row.reclaimableBytes + const key = analysisRepoKey(row) + const delta = removedByRepo.get(key) ?? { + worktreeCount: 0, + scannedWorktreeCount: 0, + unavailableWorktreeCount: 0, + totalSizeBytes: 0, + reclaimableBytes: 0 + } + delta.worktreeCount += 1 + delta.scannedWorktreeCount += scanned + delta.unavailableWorktreeCount += unavailable + delta.totalSizeBytes += row.sizeBytes + delta.reclaimableBytes += row.reclaimableBytes + removedByRepo.set(key, delta) + } + if (removedCount === 0) { + return analysis + } + return { + ...analysis, + worktrees, + worktreeCount: Math.max(0, analysis.worktreeCount - removedCount), + scannedWorktreeCount: Math.max(0, analysis.scannedWorktreeCount - scannedDelta), + unavailableWorktreeCount: Math.max(0, analysis.unavailableWorktreeCount - unavailableDelta), + totalSizeBytes: Math.max(0, analysis.totalSizeBytes - totalSizeDelta), + reclaimableBytes: Math.max(0, analysis.reclaimableBytes - reclaimableDelta), + repos: analysis.repos.map((repo) => { + const delta = removedByRepo.get(analysisRepoKey(repo)) + return delta + ? { + ...repo, + worktreeCount: Math.max(0, repo.worktreeCount - delta.worktreeCount), + scannedWorktreeCount: Math.max( + 0, + repo.scannedWorktreeCount - delta.scannedWorktreeCount + ), + unavailableWorktreeCount: Math.max( + 0, + repo.unavailableWorktreeCount - delta.unavailableWorktreeCount + ), + totalSizeBytes: Math.max(0, repo.totalSizeBytes - delta.totalSizeBytes), + reclaimableBytes: Math.max(0, repo.reclaimableBytes - delta.reclaimableBytes) + } + : repo + }) + } +} + +function analysisRepoKey(entry: { repoId: string; executionHostId?: ExecutionHostId }): string { + return JSON.stringify([entry.executionHostId, entry.repoId]) +} + +/** Register anti-resurrection tombstones without scheduling a sidecar rewrite. */ +export function registerWorkspaceSpaceAnalysisSnapshotPruneTombstones( + snapshotDirectory: string, + targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[] +): void { + if (targets.length === 0) { + return + } + registerWorkspaceSnapshotPrunesForFile( + prunedWorkspacesByFile, + sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME), + targets + ) +} + +function excludeRowsPrunedDuringScan( + file: string, + analysis: WorkspaceSpaceAnalysis +): WorkspaceSpaceAnalysis { + const prunedKeys = activeWorkspaceSnapshotPruneKeys( + prunedWorkspacesByFile.get(file), + analysis.scannedAt + ) + return withoutWorktreeRows( + analysis, + (row) => + prunedKeys.has(workspaceSnapshotPruneKey(row.worktreeId, row.executionHostId)) || + prunedKeys.has(workspaceSnapshotPruneKey(row.worktreeId)) + ) +} + +function clearSupersededPrunes(file: string, analysis: WorkspaceSpaceAnalysis): void { + const pruned = prunedWorkspacesByFile.get(file) + if (!pruned) { + return + } + for (const [key, entry] of pruned) { + if (entry.prunedAt < analysis.scannedAt) { + pruned.delete(key) + } + } + if (pruned.size === 0) { + prunedWorkspacesByFile.delete(file) + } +} + +async function pruneWorkspaceSpaceAnalysisSnapshotsWithRegisteredTombstones( + snapshotDirectory: string, + targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[], + registerTombstones: boolean +): Promise { + if (targets.length === 0) { + return + } + const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME) + const targetKeys = workspaceSnapshotPruneTargetKeys(targets) + if (registerTombstones) { + registerWorkspaceSnapshotPrunesForFile(prunedWorkspacesByFile, file, targets) + } + try { + await withSidecarSnapshotQueue(file, async () => { + const registered = prunedWorkspacesByFile.get(file) + const coalescedTargetKeys = registerTombstones + ? targetKeys + : new Set([...targetKeys].filter((key) => registered?.has(key))) + if (coalescedTargetKeys.size === 0) { + return + } + const existing = await readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory) + if (!existing) { + return + } + const next = withoutWorktreeRows( + existing, + (row) => + coalescedTargetKeys.has(workspaceSnapshotPruneKey(row.worktreeId, row.executionHostId)) || + coalescedTargetKeys.has(workspaceSnapshotPruneKey(row.worktreeId)) + ) + if (next === existing) { + return + } + await writeSnapshot(file, next) + }) + } catch (error) { + console.warn('[workspace-space] failed to prune analysis snapshot:', error) + } +} + +/** Drop removed workspace rows and rebalance their totals in one sidecar transaction. Never throws. */ +export async function pruneWorkspaceSpaceAnalysisSnapshots( + snapshotDirectory: string, + targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[] +): Promise { + await pruneWorkspaceSpaceAnalysisSnapshotsWithRegisteredTombstones( + snapshotDirectory, + targets, + true + ) +} + +/** Flush only tombstones still active for this batch, preserving their original prune time. */ +export async function finalizeWorkspaceSpaceAnalysisSnapshotPrunes( + snapshotDirectory: string, + targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[] +): Promise { + await pruneWorkspaceSpaceAnalysisSnapshotsWithRegisteredTombstones( + snapshotDirectory, + targets, + false + ) +} + +/** Drop one removed workspace row and rebalance the totals it contributed. Never throws. */ +export async function pruneWorkspaceSpaceAnalysisSnapshot( + snapshotDirectory: string, + worktreeId: string, + executionHostId?: ExecutionHostId +): Promise { + await pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, [{ worktreeId, executionHostId }]) +} diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index 53082b73c7f..d6fbc4bb2d7 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -42,14 +42,14 @@ vi.mock('./project-runtime-git-options', () => ({ import { analyzeWorkspaceSpace, WorkspaceSpaceScanCancelledError } from './workspace-space-analysis' -function createStore(repos: Repo[]): Store { +function createStore(repos: Repo[], worktreeHostId?: `runtime:${string}`): Store { return { getRepos: () => repos, getWorktreeMeta: (worktreeId: string) => { if (worktreeId.endsWith('feature')) { - return { displayName: 'Feature Workspace', lastActivityAt: 200 } + return { displayName: 'Feature Workspace', lastActivityAt: 200, hostId: worktreeHostId } } - return undefined + return worktreeHostId ? { hostId: worktreeHostId } : undefined } } as Store } @@ -199,7 +199,7 @@ describe('analyzeWorkspaceSpace', () => { ]) const progress: unknown[] = [] - await analyzeWorkspaceSpace(createStore([repo]), { + await analyzeWorkspaceSpace(createStore([repo], 'runtime:env-1'), { scanId: 'scan-1', onProgress: (event) => progress.push(event) }) @@ -221,6 +221,18 @@ describe('analyzeWorkspaceSpace', () => { scannedRepoCount: 1, scannedWorktreeCount: 1 }) + expect(progress).toContainEqual( + expect.objectContaining({ + completedMeasurements: [ + expect.objectContaining({ + worktreeId: `repo-1::${repoPath}`, + executionHostId: 'runtime:env-1', + status: 'ok', + sizeBytes: expect.any(Number) + }) + ] + }) + ) }) it('rejects when a scan is cancelled before it starts', async () => { diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index 56c9a7525cd..9b36c2d3afa 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -36,6 +36,7 @@ import { getSshGitProvider } from './providers/ssh-git-dispatch' import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' import { mergeWorktree } from './ipc/worktree-logic' import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options' +import { getRepoExecutionHostId, getWorktreeExecutionHostId } from '../shared/execution-host' const REPO_SCAN_CONCURRENCY = 2 const WORKTREE_SCAN_CONCURRENCY = 3 @@ -311,6 +312,7 @@ function createBaseWorktreeRow( return { worktreeId: worktree.id, repoId: repo.id, + executionHostId: getWorktreeExecutionHostId(worktree, repo), repoDisplayName: repo.displayName, repoPath: repo.path, displayName: worktree.displayName, @@ -703,8 +705,13 @@ function reportProgress( updates: Partial, onProgress: WorkspaceSpaceAnalyzeOptions['onProgress'] ): void { + const completedMeasurements = updates.completedMeasurements Object.assign(progress, updates, { updatedAt: Date.now() }) - onProgress?.({ ...progress }) + delete progress.completedMeasurements + onProgress?.({ + ...progress, + ...(completedMeasurements?.length ? { completedMeasurements } : {}) + }) } async function scanRepo( @@ -735,6 +742,7 @@ async function scanRepo( worktrees: [], summary: { repoId: repo.id, + executionHostId: getRepoExecutionHostId(repo), displayName: repo.displayName, path: repo.path, isRemote: Boolean(repo.connectionId), @@ -792,7 +800,17 @@ async function scanRepo( ) reportProgress( progress, - { scannedWorktreeCount: progress.scannedWorktreeCount + 1 }, + { + scannedWorktreeCount: progress.scannedWorktreeCount + 1, + completedMeasurements: [ + { + worktreeId: row.worktreeId, + executionHostId: row.executionHostId, + status: row.status, + sizeBytes: row.sizeBytes + } + ] + }, options.onProgress ) return row @@ -811,6 +829,7 @@ async function scanRepo( worktrees: rows, summary: { repoId: repo.id, + executionHostId: getRepoExecutionHostId(repo), displayName: repo.displayName, path: repo.path, isRemote: Boolean(repo.connectionId), diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index 91f4834a938..3fa28d5145e 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -55,11 +55,9 @@ export type RepositoryApi = { | 'worktreeBasePath' | 'kind' | 'issueSourcePreference' - | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' - | 'agentWorktreeVisibility' | 'customWorktreeVisibilitySources' | 'worktreeVisibilitySourcePreferences' | 'projectGroupId' @@ -67,6 +65,8 @@ export type RepositoryApi = { | 'forkSyncMode' > > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null } diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index 2524bf15a1b..348c3055a9d 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -1,7 +1,7 @@ import type { RuntimeBrowserDriverState, + RuntimeRendererSyncWindowGraph, RuntimeStatus, - RuntimeSyncWindowGraph, RuntimeSyncWindowGraphResult, RuntimeTerminalDriverState } from '../../shared/runtime-types' @@ -16,7 +16,9 @@ export type RuntimeEnvironmentSubscriptionHandle = { export type RuntimeApi = { runtime: { - syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise + syncWindowGraph: ( + graph: RuntimeRendererSyncWindowGraph + ) => Promise getStatus: () => Promise call: (args: { method: string; params?: unknown }) => Promise> subscribe: ( diff --git a/src/preload/api/workspace-cleanup-api.ts b/src/preload/api/workspace-cleanup-api.ts index 748d8216540..134dc2ee519 100644 --- a/src/preload/api/workspace-cleanup-api.ts +++ b/src/preload/api/workspace-cleanup-api.ts @@ -4,9 +4,12 @@ import type { WorkspaceCleanupLocalProcessResult, WorkspaceCleanupScanArgs, WorkspaceCleanupScanProgress, - WorkspaceCleanupScanResult + WorkspaceCleanupScanResult, + WorkspaceCleanupSnapshotPruneBatchArgs, + WorkspaceCleanupSnapshotPruneRecordArgs } from '../../shared/workspace-cleanup' import type { + WorkspaceSpaceAnalysis, WorkspaceSpaceAnalyzeResult, WorkspaceSpaceScanProgress } from '../../shared/workspace-space-types' @@ -16,15 +19,23 @@ export type WorkspaceCleanupApi = { args?: WorkspaceCleanupScanArgs, onProgress?: (progress: WorkspaceCleanupScanProgress) => void ) => Promise + cancelScan?: (scanId: string) => Promise + /** Last persisted broad-scan snapshot; null until one completes or when the cache is stale/corrupt. */ + getCachedScan: () => Promise dismiss: (args: WorkspaceCleanupDismissArgs) => Promise clearDismissals: () => Promise hasKillableLocalProcesses: ( args: WorkspaceCleanupLocalProcessArgs ) => Promise + beginRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise + recordRemovalSnapshotPrune?: (args: WorkspaceCleanupSnapshotPruneRecordArgs) => Promise + finishRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise } export type WorkspaceSpaceApi = { analyze: () => Promise + /** Last persisted analysis (topLevelItems pruned to bound the payload); null until one completes. */ + getCachedAnalysis: () => Promise cancel: () => Promise onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void } diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index 20c352af9d3..b6564d3f5db 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -93,11 +93,13 @@ export type WorktreeApi = { // may waive the proof that every PTY stopped. allowUnverifiedPtyStop?: boolean skipArchive?: boolean + snapshotPruneBatchId?: string }) => Promise // Forget a workspace from Orca only (no remote Git/FS work) — for workspaces pinned to a removed/disconnected SSH host. forgetLocal: (args: { worktreeId: string hostId?: ExecutionHostId + snapshotPruneBatchId?: string }) => Promise forceDeletePreservedBranch: (args: { worktreeId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index d6b28f90e82..8e19c6ca602 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -139,9 +139,9 @@ import type { import type { RuntimeBrowserDriverState, RuntimeMobileSessionTabMove, + RuntimeRendererSyncWindowGraph, RuntimeStatus, RuntimeSyncWindowGraphResult, - RuntimeSyncWindowGraph, RuntimeTerminalCreateRequestPayload, RuntimeTerminalDriverState, RuntimeTerminalPresentation @@ -911,14 +911,23 @@ const api = { .invoke('workspaceCleanup:scan', { ...args, scanId }) .finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener)) }, + cancelScan: (scanId) => ipcRenderer.invoke('workspaceCleanup:cancelScan', scanId), + getCachedScan: () => ipcRenderer.invoke('workspaceCleanup:getCachedScan'), dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), hasKillableLocalProcesses: (args) => - ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args) + ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args), + beginRemovalSnapshotPruneBatch: (args) => + ipcRenderer.invoke('workspaceCleanup:beginRemovalSnapshotPruneBatch', args), + recordRemovalSnapshotPrune: (args) => + ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args), + finishRemovalSnapshotPruneBatch: (args) => + ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args) } satisfies PreloadApi['workspaceCleanup'], workspaceSpace: { analyze: () => ipcRenderer.invoke('workspaceSpace:analyze'), + getCachedAnalysis: () => ipcRenderer.invoke('workspaceSpace:getCachedAnalysis'), cancel: () => ipcRenderer.invoke('workspaceSpace:cancel'), onProgress: (callback) => { const listener = ( @@ -4326,7 +4335,9 @@ const api = { }, runtime: { - syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => + syncWindowGraph: ( + graph: RuntimeRendererSyncWindowGraph + ): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), call: (args: { method: string; params?: unknown }): Promise> => diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index 7c92ca1d618..bf1e4b6e36e 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -10,10 +10,11 @@ import { ChevronLeft, ChevronRight, Clipboard, + Copy, + ExternalLink, FolderKanban, FolderOpen, GitBranch, - Link, LoaderCircle, Plus, RefreshCw, @@ -782,6 +783,7 @@ export default function LinearIssueWorkspace({ @@ -800,19 +802,20 @@ export default function LinearIssueWorkspace({ - {translate('auto.components.LinearIssueWorkspace.30c1242f3a', 'Copy identifier')} + {translate('auto.components.LinearIssueWorkspace.openOnLinear', 'Open on Linear')} {attachedWorkspace ? ( @@ -858,24 +861,19 @@ export default function LinearIssueWorkspace({ ) : ( - - - - - - {translate('auto.components.LinearIssueWorkspace.e1e0a9bca9', 'Start workspace')} - - + )} {variant === 'sheet' ? ( diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index bbf86117bef..f500667d401 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -728,6 +728,7 @@ function WorktreeJumpPaletteContent({ const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) const sleepingAgentSessionsByPaneKey = useAppStore((s) => s.sleepingAgentSessionsByPaneKey) const settings = useAppStore((s) => s.settings) + const worktreeVisibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) @@ -1497,7 +1498,9 @@ function WorktreeJumpPaletteContent({ } for (const repoId of buildImportedWorktreesCardCandidates({ repos, - detectedWorktreesByRepo + detectedWorktreesByRepo, + settings, + visibilityDefaultsByHost: worktreeVisibilityDefaultsByHost }).keys()) { ids.add(repoId) } @@ -1505,7 +1508,15 @@ function WorktreeJumpPaletteContent({ ids.add(creation.request.repoId) } return ids - }, [allWorktrees, detectedWorktreesByRepo, pendingWorktreeCreations, repos, worktreesByRepo]) + }, [ + allWorktrees, + detectedWorktreesByRepo, + pendingWorktreeCreations, + repos, + settings, + worktreeVisibilityDefaultsByHost, + worktreesByRepo + ]) const hasAnyProjectSearchCandidates = useMemo( () => hasCmdJProjectSearchCandidates({ diff --git a/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx new file mode 100644 index 00000000000..05ce6113726 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx @@ -0,0 +1,416 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import type * as AgentMapLayoutModule from './agent-map-layout' +import type * as AgentMapProjectPlacementModule from './agent-map-project-placement' +import { AGENT_MAP_TIME_MAX_INDEX, type AgentMapTimeRange } from './agent-map-time-filter' + +/** Counts the packing work one slider interaction costs. `repacks` only rises + * when `updateAgentMapLayout` misses its topology cache and runs the full + * `deriveAgentMapLayout` again; `updates` counts every layout evaluation. */ +const layoutCalls = vi.hoisted(() => ({ updates: 0, repacks: 0 })) +const packCalls = vi.hoisted(() => ({ count: 0 })) + +vi.mock('./agent-map-layout', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + updateAgentMapLayout: ( + ...args: Parameters + ): ReturnType => { + layoutCalls.updates += 1 + const result = actual.updateAgentMapLayout(...args) + // A fresh cache object is returned only on the deriveAgentMapLayout path. + if (result.cache !== args[0]) { + layoutCalls.repacks += 1 + } + return result + } + } +}) + +// Second, independent counter: the packer runs once per non-empty repack. +vi.mock('./agent-map-project-placement', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + placeAgentMapProjects: ( + ...args: Parameters + ): ReturnType => { + packCalls.count += 1 + return actual.placeAgentMapProjects(...args) + } + } +}) + +import { AgentDashboardMapView } from './AgentDashboardMapView' +import { AgentMapTimeRangeField } from './AgentMapTimeRangeField' + +const NOW = 2_000_000_000 +const MINUTE = 60_000 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +const SLIDER_WIDTH = 280 +/** Radix maps pointer x linearly onto [0, AGENT_MAP_TIME_MAX_INDEX]. */ +const clientXForStop = (stop: number): number => (stop / AGENT_MAP_TIME_MAX_INDEX) * SLIDER_WIDTH + +function card(overrides: Partial & { paneKey: string }): DashboardCard { + return { + ptyId: overrides.paneKey, + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Pack the map', + repoId: 'repo-1', + worktreeId: `worktree-${overrides.paneKey}`, + tabId: 'tab-1', + leafId: `leaf-${overrides.paneKey}`, + repoName: 'Orca', + worktreeName: overrides.paneKey, + startedAt: NOW - MINUTE, + finishedAt: null, + stateChangedAt: NOW - 1_000, + statusUpdatedAt: NOW - 1_000, + unseen: false, + hostKind: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + +/** One card per stop the drag crosses, each just old enough to be dropped by + * the next step down — so every value change is a real topology change. */ +const LIFESPANS: readonly { paneKey: string; lifespan: number }[] = [ + { paneKey: 'agent-20d', lifespan: 20 * DAY }, + { paneKey: 'agent-10d', lifespan: 10 * DAY }, + { paneKey: 'agent-5d', lifespan: 5 * DAY }, + { paneKey: 'agent-2_5d', lifespan: 2.5 * DAY }, + { paneKey: 'agent-36h', lifespan: 36 * HOUR }, + { paneKey: 'agent-18h', lifespan: 18 * HOUR }, + { paneKey: 'agent-5m', lifespan: 5 * MINUTE } +] + +const CARDS: DashboardCard[] = LIFESPANS.map(({ paneKey, lifespan }) => + card({ paneKey, startedAt: NOW - lifespan }) +) + +const SNAPSHOT: DashboardSnapshot = { + generatedAt: NOW, + cards: CARDS, + workspaces: [], + filterOptions: { projects: [], workspaceStatuses: [] } +} + +/** Stops the max thumb passes through on one drag: ∞ → 12h. */ +const DRAG_STOPS = [13, 12, 11, 10, 9, 8] +const EXPECTED_DRAG_REPACKS = 1 +const DRAFT_CANCELLATIONS = [ + { name: 'pointer cancellation', finish: (thumb: HTMLElement) => fireEvent.pointerCancel(thumb) }, + { + name: 'pointer capture loss', + finish: (thumb: HTMLElement) => fireEvent.lostPointerCapture(thumb, { pointerId: 1 }) + }, + { name: 'focus loss', finish: (thumb: HTMLElement) => fireEvent.blur(thumb) }, + { name: 'Escape', finish: (thumb: HTMLElement) => fireEvent.keyDown(thumb, { key: 'Escape' }) } +] + +function renderMapView(): ReturnType { + return render( + + ) +} + +async function openTimeSection(): Promise { + fireEvent.click(screen.getByRole('button', { name: /^Filter/ })) + fireEvent.click(await screen.findByRole('button', { name: /^Time/ })) + const slider = await screen.findByRole('slider', { name: 'Session lifespan maximum' }) + return slider +} + +/** Mirrors the panel's wiring: the field is controlled and the owner re-renders + * on every published range. */ +function ControlledField({ + label, + initial, + onChange +}: { + label: string + initial: AgentMapTimeRange + onChange: (range: AgentMapTimeRange) => void +}): React.JSX.Element { + const [range, setRange] = useState(initial) + return ( + { + setRange(next) + onChange(next) + }} + /> + ) +} + +/** Radix reads geometry off the root and gates moves on pointer capture. */ +function stubSliderGeometry(): () => void { + const captured = new Set() + const element = Element.prototype as unknown as { + setPointerCapture: (id: number) => void + hasPointerCapture: (id: number) => boolean + releasePointerCapture: (id: number) => void + } + const original = { + setPointerCapture: element.setPointerCapture, + hasPointerCapture: element.hasPointerCapture, + releasePointerCapture: element.releasePointerCapture + } + element.setPointerCapture = (id) => void captured.add(id) + element.hasPointerCapture = (id) => captured.has(id) + element.releasePointerCapture = (id) => void captured.delete(id) + const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: SLIDER_WIDTH, + bottom: 24, + width: SLIDER_WIDTH, + height: 24, + toJSON: () => ({}) + }) + return () => { + element.setPointerCapture = original.setPointerCapture + element.hasPointerCapture = original.hasPointerCapture + element.releasePointerCapture = original.releasePointerCapture + rect.mockRestore() + } +} + +function dragThumb(thumb: HTMLElement, stops: readonly number[], onStep?: () => void): void { + act(() => { + fireEvent.pointerDown(thumb, { pointerId: 1, button: 0, clientX: SLIDER_WIDTH }) + }) + for (const stop of stops) { + act(() => { + fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(stop) }) + }) + onStep?.() + } + act(() => { + fireEvent.pointerUp(thumb, { pointerId: 1, clientX: clientXForStop(stops.at(-1) ?? 0) }) + }) +} + +describe('AgentMapTimeRangeField', () => { + let restoreGeometry: () => void + + beforeEach(() => { + layoutCalls.updates = 0 + layoutCalls.repacks = 0 + packCalls.count = 0 + restoreGeometry = stubSliderGeometry() + }) + + afterEach(() => { + restoreGeometry() + cleanup() + vi.restoreAllMocks() + }) + + it('repacks the whole map once when a multi-stop drag commits', async () => { + renderMapView() + await waitFor(() => expect(document.querySelector('.agent-map-canvas')).toBeTruthy()) + const mountRepacks = layoutCalls.repacks + const mountUpdates = layoutCalls.updates + const mountPacks = packCalls.count + + const thumb = await openTimeSection() + dragThumb(thumb, DRAG_STOPS) + + expect(layoutCalls.repacks - mountRepacks).toBe(EXPECTED_DRAG_REPACKS) + expect(packCalls.count - mountPacks).toBe(EXPECTED_DRAG_REPACKS) + expect(layoutCalls.updates - mountUpdates).toBe(EXPECTED_DRAG_REPACKS) + expect(screen.getByText('of 7 agents shown').parentElement).toHaveTextContent( + '1 of 7 agents shown' + ) + await waitFor(() => expect(document.querySelectorAll('[data-agent-map-agent]')).toHaveLength(1)) + }) + + it('updates the thumb and readout at every intermediate drag stop', async () => { + renderMapView() + await waitFor(() => expect(document.querySelector('.agent-map-canvas')).toBeTruthy()) + const thumb = await openTimeSection() + const field = thumb.closest('[data-slot="slider"]')?.parentElement as HTMLElement + const readouts: string[] = [] + const thumbValues: string[] = [] + + dragThumb(thumb, DRAG_STOPS, () => { + readouts.push(within(field).getByText(/–|any/).textContent ?? '') + thumbValues.push(thumb.getAttribute('aria-valuenow') ?? '') + }) + + expect(readouts).toEqual(['0 – 14d', '0 – 7d', '0 – 3d', '0 – 2d', '0 – 1d', '0 – 12h']) + expect(thumbValues).toEqual(DRAG_STOPS.map(String)) + }) + + it('keeps the readout, chip, and map aligned after a full-range collapse', async () => { + renderMapView() + await waitFor(() => expect(document.querySelector('.agent-map-canvas')).toBeTruthy()) + + const thumb = await openTimeSection() + dragThumb(thumb, [0]) + + expect(screen.getByText('0 – 0')).toBeInTheDocument() + expect(screen.getByText('Session lifespan: 0–0')).toBeInTheDocument() + expect(screen.getByText('of 7 agents shown').parentElement).toHaveTextContent( + '0 of 7 agents shown' + ) + await waitFor(() => expect(document.querySelectorAll('[data-agent-map-agent]')).toHaveLength(0)) + }) + + it('publishes only the final range for a multi-stop drag', () => { + const onChange = vi.fn() + render( + + ) + + dragThumb(screen.getByRole('slider', { name: 'Session lifespan maximum' }), DRAG_STOPS) + + expect(onChange).toHaveBeenCalledExactlyOnceWith({ min: 0, max: DRAG_STOPS.at(-1) }) + }) + + it.each([ + { name: 'a narrowed range', initial: { min: 5, max: AGENT_MAP_TIME_MAX_INDEX }, stop: 5 }, + { name: 'the full range', initial: { min: 0, max: AGENT_MAP_TIME_MAX_INDEX }, stop: 0 } + ])('commits max-thumb collapse from $name', ({ initial, stop }) => { + const onChange = vi.fn() + render() + + dragThumb(screen.getByRole('slider', { name: 'Session lifespan maximum' }), [stop]) + + expect(onChange).toHaveBeenCalledExactlyOnceWith({ min: stop, max: stop }) + expect( + screen.getByText(`${stop === 0 ? '0' : '1h'} – ${stop === 0 ? '0' : '1h'}`) + ).toBeInTheDocument() + }) + + it('follows an external range change while a draft is active', () => { + const onChange = vi.fn() + const field = (range: AgentMapTimeRange): React.JSX.Element => ( + + ) + const view = render(field({ min: 0, max: AGENT_MAP_TIME_MAX_INDEX })) + expect(screen.getByText('any')).toBeInTheDocument() + + view.rerender(field({ min: 4, max: 9 })) + + expect(screen.getByText('30m – 1d')).toBeInTheDocument() + expect(screen.getByRole('slider', { name: 'Session lifespan minimum' })).toHaveAttribute( + 'aria-valuenow', + '4' + ) + expect(screen.getByRole('slider', { name: 'Session lifespan maximum' })).toHaveAttribute( + 'aria-valuenow', + '9' + ) + + const thumb = screen.getByRole('slider', { name: 'Session lifespan maximum' }) + act(() => { + fireEvent.pointerDown(thumb, { pointerId: 1, button: 0, clientX: clientXForStop(9) }) + fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(7) }) + }) + expect(screen.getByText('30m – 6h')).toBeInTheDocument() + + view.rerender(field({ min: 0, max: AGENT_MAP_TIME_MAX_INDEX })) + + expect(screen.getByText('any')).toBeInTheDocument() + expect(screen.getByRole('slider', { name: 'Session lifespan maximum' })).toHaveAttribute( + 'aria-valuenow', + String(AGENT_MAP_TIME_MAX_INDEX) + ) + }) + + it('does not commit an interaction invalidated by an external range change', () => { + const onChange = vi.fn() + const field = (range: AgentMapTimeRange): React.JSX.Element => ( + + ) + const view = render(field({ min: 0, max: AGENT_MAP_TIME_MAX_INDEX })) + const thumb = screen.getByRole('slider', { name: 'Session lifespan maximum' }) + + act(() => { + fireEvent.pointerDown(thumb, { pointerId: 1, button: 0, clientX: SLIDER_WIDTH }) + fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(8) }) + }) + view.rerender(field({ min: 4, max: 9 })) + act(() => { + fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(7) }) + fireEvent.pointerUp(thumb, { pointerId: 1, clientX: clientXForStop(7) }) + }) + + expect(onChange).not.toHaveBeenCalled() + }) + + it.each(DRAFT_CANCELLATIONS)('discards a pointer draft on $name', ({ finish }) => { + const onChange = vi.fn() + render( + + ) + const thumb = screen.getByRole('slider', { name: 'Session lifespan maximum' }) + + act(() => { + fireEvent.pointerDown(thumb, { pointerId: 1, button: 0, clientX: SLIDER_WIDTH }) + fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(8) }) + }) + expect(screen.getByText('0 – 12h')).toBeInTheDocument() + finish(thumb) + + expect(screen.getByText('any')).toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() + }) + + it('commits a keyboard arrow step', () => { + const onChange = vi.fn() + render( + + ) + + const thumb = screen.getByRole('slider', { name: 'Session lifespan maximum' }) + // Radix routes arrow keys to the last focused thumb, not the event target. + act(() => thumb.focus()) + fireEvent.keyDown(thumb, { key: 'ArrowLeft' }) + + expect(onChange).toHaveBeenCalledExactlyOnceWith({ min: 0, max: AGENT_MAP_TIME_MAX_INDEX - 1 }) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.tsx b/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.tsx index 1ad8132a879..752a00ea673 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.tsx @@ -1,6 +1,7 @@ import { Slider } from '@/components/ui/slider' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { useRef, useState } from 'react' import { AGENT_MAP_TIME_MAX_INDEX, agentMapTimeStopLabel, @@ -14,16 +15,46 @@ type AgentMapTimeRangeFieldProps = { onChange: (range: AgentMapTimeRange) => void } +type SliderInteraction = { + source: AgentMapTimeRange + value: AgentMapTimeRange | null +} + /** Ticks are sparse on purpose — the scale is non-linear, so labelling every * stop would read as evenly spaced time when it is not. */ const TICKS = [0, 5, 9, 12, AGENT_MAP_TIME_MAX_INDEX] +const SLIDER_KEYBOARD_COMMIT_KEYS = [ + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'End', + 'Home', + 'PageDown', + 'PageUp' +] export function AgentMapTimeRangeField({ label, range, onChange }: AgentMapTimeRangeFieldProps): React.JSX.Element { - const isFull = isFullAgentMapTimeRange(range) + const [draft, setDraft] = useState<{ + source: AgentMapTimeRange + value: AgentMapTimeRange + } | null>(null) + const interaction = useRef(null) + const reconcileInteraction = (value?: AgentMapTimeRange): void => { + const source = interaction.current?.source + interaction.current = null + setDraft(null) + if (value && source === range) { + onChange(value) + } + } + // New external range objects invalidate stale drafts from resets and quick views. + const displayedRange = draft?.source === range ? draft.value : range + const isFull = isFullAgentMapTimeRange(displayedRange) return (
@@ -36,7 +67,7 @@ export function AgentMapTimeRangeField({ > {isFull ? translate('dashboardPopout.map.filters.timeAny', 'any') - : `${agentMapTimeStopLabel(range.min)} – ${agentMapTimeStopLabel(range.max)}`} + : `${agentMapTimeStopLabel(displayedRange.min)} – ${agentMapTimeStopLabel(displayedRange.max)}`}
onChange({ min, max })} + thumbValueLabels={[ + agentMapTimeStopLabel(displayedRange.min), + agentMapTimeStopLabel(displayedRange.max) + ]} + onKeyDown={(event) => { + if (event.key === 'Escape') { + reconcileInteraction() + return + } + if (SLIDER_KEYBOARD_COMMIT_KEYS.includes(event.key)) { + interaction.current = { source: range, value: null } + } + }} + onPointerDown={() => { + interaction.current = { source: range, value: null } + }} + onPointerUp={() => { + reconcileInteraction(interaction.current?.value ?? undefined) + }} + onPointerCancel={() => { + reconcileInteraction() + }} + onLostPointerCapture={() => { + reconcileInteraction() + }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + reconcileInteraction() + } + }} + onValueChange={([min, max]) => { + const current = interaction.current + if (!current) { + return + } + const value = { min, max } + current.value = value + setDraft({ source: current.source, value }) + }} + onValueCommit={([min, max]) => { + reconcileInteraction({ min, max }) + }} />
{TICKS.map((tick) => ( diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx b/src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx index 35c4cab5055..1cdddd5570a 100644 --- a/src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx +++ b/src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx @@ -2,7 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { AGENT_MAP_TIME_MAX_INDEX } from './agent-map-time-filter' +import { AGENT_MAP_TIME_FIELDS, AGENT_MAP_TIME_MAX_INDEX } from './agent-map-time-filter' import { useAgentMapFilters } from './useAgentMapFilters' describe('useAgentMapFilters', () => { @@ -47,4 +47,23 @@ describe('useAgentMapFilters', () => { expect([...hook.result.current.agentTypes]).toEqual(['claude', 'grok']) }) + + it('preserves each time-range identity across unrelated facet updates', () => { + let agentTypes = ['claude', 'codex'] + const hook = renderHook(() => useAgentMapFilters(agentTypes)) + const ranges = hook.result.current.timeRanges + const fields = AGENT_MAP_TIME_FIELDS.map((field) => ranges[field]) + + act(() => hook.result.current.toggleState('done')) + act(() => hook.result.current.toggleAgentType('claude')) + act(() => hook.result.current.setUnreadOnly(true)) + act(() => hook.result.current.setOrchestrationOnly(true)) + agentTypes = ['claude', 'codex', 'grok'] + hook.rerender() + + expect(hook.result.current.timeRanges).toBe(ranges) + AGENT_MAP_TIME_FIELDS.forEach((field, index) => { + expect(hook.result.current.timeRanges[field]).toBe(fields[index]) + }) + }) }) diff --git a/src/renderer/src/components/linear-issue-workspace-header.test.ts b/src/renderer/src/components/linear-issue-workspace-header.test.ts new file mode 100644 index 00000000000..a8d52b64166 --- /dev/null +++ b/src/renderer/src/components/linear-issue-workspace-header.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(__dirname, 'LinearIssueWorkspace.tsx'), 'utf8') + +function headerSource(): string { + const start = source.indexOf('
') + expect(header).toContain('') + expect(header).toContain("'Start workspace'") + expect(header).toContain("'Start workspace from issue'") + expect(header).not.toContain("'Copy issue identifier'") + expect(header).toMatch(/size="sm"[\s\S]*onClick=\{handleOpenOrUseIssue\}/) + expect(header).not.toMatch(/size="icon-sm"\s+onClick=\{handleOpenOrUseIssue\}/) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts index 37850bccaec..e653a29258f 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -719,7 +719,7 @@ describe('native chat PTY session options', () => { expect(surface.getSnapshot()[0]).toMatchObject({ id: 'model', valueSource: 'default', - kind: { currentValue: 'grok-4.5' } + kind: { currentValue: 'grok-4.6' } }) // Regression: the effort row hangs off that default model, so resolving the apply @@ -735,9 +735,9 @@ describe('native chat PTY session options', () => { it('does not adopt grok’s unprobed seed default as a persisted launch model', async () => { // Regression: setting an option under the CLI default wrote `model` into settings, - // so every later grok launch app-wide emitted `-m grok-4.5` — on an account without + // so every later grok launch app-wide emitted `-m grok-4.6` — on an account without // that model, a fatal launch the user never opted into. No discovery has run here, - // so `grok-4.5` is still only the seed's guess. + // so `grok-4.6` is still only the seed's guess. let persisted: PersistedNativeChatSessionOptions = {} const surface = createNativeChatPtySessionOptions({ agent: 'grok', @@ -760,7 +760,7 @@ describe('native chat PTY session options', () => { expect(persisted.grok?.model).toBeUndefined() // The scoped value is still remembered for a later explicit pick of that model. - expect(persisted.grok?.valuesByModel?.['grok-4.5']?.effort).toBe('low') + expect(persisted.grok?.valuesByModel?.['grok-4.6']?.effort).toBe('low') expect(resolveNativeChatSessionOptionDefaults(persisted, 'grok')).toBeUndefined() // A model the user actually picks still becomes the launch default. diff --git a/src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx b/src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx index 07129f53077..5aee94a061d 100644 --- a/src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx @@ -76,4 +76,29 @@ describe('GitHistoryPanel', () => { expect(markup).toContain('Fix tab overflow') expect(markup).toContain('52ad492') }) + + it('does not add native title tooltips alongside managed history tooltips', () => { + const result = makeHistoryResult() + result.items[0].references = [ + { id: 'refs/heads/main', name: 'main', category: 'branches' }, + { id: 'refs/heads/feature', name: 'feature', category: 'branches' }, + { id: 'refs/tags/v1.0.0', name: 'v1.0.0', category: 'tags' } + ] + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('title="Fix tab overflow"') + expect(markup).not.toContain('title="main"') + expect(markup).not.toContain('title="feature"') + expect(markup).not.toContain('title="v1.0.0"') + expect(markup).not.toMatch(/\stitle=/) + }) }) diff --git a/src/renderer/src/components/right-sidebar/GitHistoryRow.tsx b/src/renderer/src/components/right-sidebar/GitHistoryRow.tsx index f41ea2f92b4..f2c3f020178 100644 --- a/src/renderer/src/components/right-sidebar/GitHistoryRow.tsx +++ b/src/renderer/src/components/right-sidebar/GitHistoryRow.tsx @@ -20,7 +20,6 @@ function GitHistoryRefBadge({ itemRef }: { itemRef: GitHistoryItemRef }): React. borderColor: itemRef.color ? graphColor(itemRef.color) : 'var(--border)', color: itemRef.color ? graphColor(itemRef.color) : 'var(--muted-foreground)' }} - title={itemRef.name} > {itemRef.name} @@ -90,9 +89,7 @@ export const GitHistoryRow = React.forwardRef( )} - - {item.subject} - + {item.subject} ( {hiddenRefs.length > 0 && ( - ref.name).join(', ')} - > + +{hiddenRefs.length} @@ -135,7 +129,6 @@ export const GitHistoryRow = React.forwardRef( {...rootProps} ref={ref as React.Ref} className={rowClassName} - title={rowTooltip} data-testid="git-history-row" > {rowContent} @@ -157,7 +150,6 @@ export const GitHistoryRow = React.forwardRef( ref={ref as React.Ref} type="button" className={rowClassName} - title={rowTooltip} aria-expanded={canExpand ? expanded : undefined} aria-label={ canExpand diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index f688f500567..ba7847f7ce0 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -23,6 +23,7 @@ import { SearchableSetting } from './SearchableSetting' import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' import { translate } from '@/i18n/i18n' import { DefaultWindowsProjectRuntimeSetting } from './DefaultWindowsProjectRuntimeSetting' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' export { createAutoSaveDelayDraftState, @@ -79,6 +80,7 @@ const EMPTY_WSL_DISTROS: string[] = [] type GeneralPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void + updateSettingsOrThrow?: (updates: Partial) => void | Promise fontSuggestions: string[] onRequestFontSuggestions?: () => void wslSupportedPlatform?: boolean @@ -90,6 +92,7 @@ type GeneralPaneProps = { export function GeneralPane({ settings, updateSettings, + updateSettingsOrThrow, fontSuggestions, onRequestFontSuggestions, wslSupportedPlatform, @@ -98,6 +101,20 @@ export function GeneralPane({ wslCapabilitiesLoading }: GeneralPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) + const sourceDefaultsSupportedRuntimeEnvironmentId = useAppStore( + (s) => s.worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId + ) + const defaultsSupportedRuntimeEnvironmentId = useAppStore( + (s) => s.worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId + ) + const activeRuntimeTarget = getActiveRuntimeTarget(settings) + const defaultsSupported = + activeRuntimeTarget.kind === 'local' || + activeRuntimeTarget.environmentId === defaultsSupportedRuntimeEnvironmentId + const sourceDefaultsSupported = + defaultsSupported && + (activeRuntimeTarget.kind === 'local' || + activeRuntimeTarget.environmentId === sourceDefaultsSupportedRuntimeEnvironmentId) const generalNavigationSearchEntries = getGeneralNavigationSearchEntries() const tabOrderKeywords = getTabOrderControlSearchKeywords(generalNavigationSearchEntries) const projectRuntimeSearchEntries = wslSupportedPlatform @@ -148,6 +165,9 @@ export function GeneralPane({ key="workspace" settings={settings} updateSettings={updateSettings} + updateSettingsOrThrow={updateSettingsOrThrow} + defaultsSupported={defaultsSupported} + sourceDefaultsSupported={sourceDefaultsSupported} /> ) : null, shouldShowProjectRuntimeSection( diff --git a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx new file mode 100644 index 00000000000..a601584f60e --- /dev/null +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { GeneralWorkspaceSettingsSection } from './GeneralWorkspaceSettingsSection' +import type { ReactNode } from 'react' + +vi.mock('./WorkspaceDirectorySetting', () => ({ WorkspaceDirectorySetting: () => null })) +vi.mock('./OpenInMenuSetting', () => ({ OpenInMenuSetting: () => null })) +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children} +})) + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function renderSection( + updateSettings: (updates: object) => void | Promise, + options: { + defaultsSupported?: boolean + sourceDefaultsSupported?: boolean + worktreeVisibilityDefaults?: ReturnType['worktreeVisibilityDefaults'] + } = {} +): void { + const settings = getDefaultSettings('/home/user') + settings.worktreeVisibilityDefaults = options.worktreeVisibilityDefaults + act(() => { + root.render( + + ) + }) +} + +function getSegment(label: string, visibility: 'show' | 'hide' = 'show'): HTMLButtonElement { + const control = container.querySelector( + `[aria-label="Visibility for ${label}"] [data-visibility="${visibility}"]` + ) + if (!control) { + throw new Error(`${visibility} segment not found: ${label}`) + } + return control +} + +describe('GeneralWorkspaceSettingsSection external visibility', () => { + it('exposes a stable deep-link target for global defaults', () => { + renderSection(vi.fn()) + + expect( + container.querySelector('[data-settings-section="general-global-worktree-visibility"]') + ).not.toBeNull() + }) + + it('writes the global Other locations default without touching repositories', async () => { + const updateSettings = vi.fn() + renderSection(updateSettings) + + await act(async () => { + getSegment('Other locations').click() + }) + + expect(updateSettings).toHaveBeenCalledWith({ + worktreeVisibilityDefaults: { external: 'show' } + }) + }) + + it('keeps Other locations available while source defaults require a newer host', () => { + renderSection(vi.fn(), { defaultsSupported: true, sourceDefaultsSupported: false }) + + expect(getSegment('Claude Code').disabled).toBe(true) + expect(getSegment('GSD').disabled).toBe(true) + expect(getSegment('Other locations').disabled).toBe(false) + expect(container.querySelector('#custom-worktree-root')?.disabled).toBe(true) + expect(container.textContent).toContain('Update this server to configure source defaults.') + }) + + it('disables all visibility defaults when the paired host lacks base support', () => { + renderSection(vi.fn(), { defaultsSupported: false, sourceDefaultsSupported: false }) + + expect(getSegment('Claude Code').disabled).toBe(true) + expect(getSegment('GSD').disabled).toBe(true) + expect(getSegment('Other locations').disabled).toBe(true) + expect(container.querySelector('#custom-worktree-root')?.disabled).toBe(true) + expect(container.textContent).toContain('Update this server to configure visibility defaults.') + }) + + it('writes only Other locations when a downgraded host retains source fields', async () => { + const updateSettings = vi.fn() + renderSection(updateSettings, { + sourceDefaultsSupported: false, + worktreeVisibilityDefaults: { + external: 'hide', + customSources: [{ id: 'team', rootPath: '/srv/team' }], + sourcePreferences: { builtIn: { claude: 'show' } } + } + }) + + await act(async () => { + getSegment('Other locations').click() + }) + + expect(updateSettings).toHaveBeenCalledWith({ + worktreeVisibilityDefaults: { external: 'show' } + }) + }) + + it('adds a global location disabled by default', async () => { + const updateSettings = vi.fn() + renderSection(updateSettings) + const input = container.querySelector('#custom-worktree-root')! + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + act(() => { + setValue?.call(input, '/srv/team-worktrees') + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + + await act(async () => { + container.querySelector('button[type="submit"]')?.click() + }) + + const defaults = updateSettings.mock.calls[0]?.[0].worktreeVisibilityDefaults + expect(defaults).toMatchObject({ + external: 'hide', + customSources: [{ rootPath: '/srv/team-worktrees' }], + sourcePreferences: { custom: expect.any(Object) } + }) + const sourceId = defaults.customSources[0].id + expect(defaults.sourcePreferences.custom).toEqual({ [sourceId]: 'hide' }) + }) +}) diff --git a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx index 7531790a7a1..41f803040c1 100644 --- a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx @@ -5,15 +5,23 @@ import { SearchableSetting } from './SearchableSetting' import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' import { WorkspaceDirectorySetting } from './WorkspaceDirectorySetting' import { translate } from '@/i18n/i18n' +import { GlobalWorktreeVisibilitySourcesSetting } from './GlobalWorktreeVisibilitySourcesSetting' +import { GLOBAL_WORKTREE_VISIBILITY_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' type GeneralWorkspaceSettingsSectionProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void + updateSettingsOrThrow?: (updates: Partial) => void | Promise + defaultsSupported?: boolean + sourceDefaultsSupported?: boolean } export function GeneralWorkspaceSettingsSection({ settings, - updateSettings + updateSettings, + updateSettingsOrThrow = updateSettings, + defaultsSupported = true, + sourceDefaultsSupported = true }: GeneralWorkspaceSettingsSectionProps): React.JSX.Element { return (
@@ -30,6 +38,44 @@ export function GeneralWorkspaceSettingsSection({ +
+ + + +
+ ) => void | Promise +} + +const DEFAULT_WORKTREE_VISIBILITY_DEFAULTS: WorktreeVisibilityDefaults = { external: 'hide' } + +export function GlobalWorktreeVisibilitySourcesSetting({ + settings, + defaultsSupported, + sourceDefaultsSupported, + updateSettings +}: Props): React.JSX.Element { + const defaults = settings.worktreeVisibilityDefaults ?? DEFAULT_WORKTREE_VISIBILITY_DEFAULTS + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + const pendingRef = useRef(false) + const customSources = useMemo( + () => normalizeCustomWorktreeVisibilitySources(defaults.customSources) ?? [], + [defaults.customSources] + ) + const removableSourceIds = useMemo( + () => new Set(customSources.map((source) => source.id)), + [customSources] + ) + + const commit = useCallback( + async (next: WorktreeVisibilityDefaults): Promise => { + if (pendingRef.current) { + return false + } + pendingRef.current = true + setPending(true) + setError(null) + try { + await updateSettings({ worktreeVisibilityDefaults: next }) + return true + } catch { + setError( + translate( + 'auto.components.settings.GlobalWorktreeVisibilitySourcesSetting.saveFailed', + 'Could not save visibility defaults.' + ) + ) + return false + } finally { + pendingRef.current = false + setPending(false) + } + }, + [updateSettings] + ) + + const handleToggle = useCallback( + async (source: WorktreeVisibilitySourceRow, checked: boolean) => { + const visibility = checked ? 'show' : 'hide' + if (source.kind === 'other') { + await commit( + sourceDefaultsSupported ? { ...defaults, external: visibility } : { external: visibility } + ) + return + } + if (!sourceDefaultsSupported) { + return + } + const match = + source.kind === 'built-in' + ? ({ kind: 'built-in', id: source.id } as const) + : ({ kind: 'custom', id: source.source.id } as const) + await commit({ + ...defaults, + sourcePreferences: buildDefaultWorktreeSourcePreferenceUpdate(defaults, match, visibility) + }) + }, + [commit, defaults, sourceDefaultsSupported] + ) + + const handleAdd = useCallback( + async (rootPath: string): Promise => { + if (!sourceDefaultsSupported) { + return 'save-failed' + } + if (customSources.length >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) { + return 'limit' + } + const id = crypto.randomUUID().replaceAll('-', '') + const candidate = normalizeCustomWorktreeVisibilitySources([{ id, rootPath }])?.[0] + if (!candidate) { + return 'invalid-path' + } + const nextSources = normalizeCustomWorktreeVisibilitySources([...customSources, candidate]) + if (!nextSources || nextSources.length !== customSources.length + 1) { + return 'duplicate-path' + } + const saved = await commit({ + ...defaults, + customSources: nextSources, + sourcePreferences: buildDefaultWorktreeSourcePreferenceUpdate( + defaults, + { kind: 'custom', id }, + 'hide' + ) + }) + return saved ? 'added' : 'save-failed' + }, + [commit, customSources, defaults, sourceDefaultsSupported] + ) + + const handleRemove = useCallback( + async (source: CustomWorktreeVisibilitySource) => { + if (!sourceDefaultsSupported) { + return + } + await commit({ + ...defaults, + customSources: customSources.filter((candidate) => candidate.id !== source.id), + sourcePreferences: removeDefaultCustomWorktreeSourcePreference(defaults, source.id) + }) + }, + [commit, customSources, defaults, sourceDefaultsSupported] + ) + + return ( +
+ + {!defaultsSupported ? ( +

+ {translate( + 'auto.components.settings.GlobalWorktreeVisibilitySourcesSetting.updateServerDefaults', + 'Update this server to configure visibility defaults.' + )} +

+ ) : !sourceDefaultsSupported ? ( +

+ {translate( + 'auto.components.settings.GlobalWorktreeVisibilitySourcesSetting.updateServer', + 'Update this server to configure source defaults.' + )} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 6b0963ef009..02874e5edc4 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -29,11 +29,16 @@ import { RepositoryWindowsRuntimeSection } from './RepositoryWindowsRuntimeSecti import { matchesRepositoryIdentitySearch } from './repository-identity-search' import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection' import { getProjectRuntimeSessionSummary } from './repository-runtime-session-summary' +import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host' export { getRepositoryPaneSearchEntries } export { matchesRepositoryIdentitySearch } from './repository-identity-search' -type RepositoryPaneRepoUpdate = Omit, 'sourceControlAi'> & { +type RepositoryPaneRepoUpdate = Omit< + Partial, + 'sourceControlAi' | 'externalWorktreeVisibility' +> & { sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null } const EMPTY_WSL_DISTROS: string[] = [] @@ -48,7 +53,7 @@ type RepositoryPaneProps = { repoId: string, updates: RepositoryPaneRepoUpdate, options?: { hostId?: ExecutionHostId } - ) => void + ) => void | Promise removeProject: (repoId: string) => void project?: Project | null selectedProjectSetupId?: string @@ -91,6 +96,20 @@ export function RepositoryPane({ ) const searchQuery = useAppStore((state) => state.settingsSearchQuery) const settings = useAppStore((state) => state.settings) + const worktreeVisibilityDefaultsByHost = useAppStore( + (state) => state.worktreeVisibilityDefaultsByHost + ) + const repoOwnerSettings = settings + ? { + ...settings, + worktreeVisibilityDefaults: getRepoOwnerWorktreeVisibilityDefaults( + repo, + settings, + worktreeVisibilityDefaultsByHost + ) + } + : null + const fetchWorktrees = useAppStore((state) => state.fetchWorktrees) const runtimeSessionSummary = useAppStore( useShallow((state) => getProjectRuntimeSessionSummary(state, repo.id)) ) @@ -166,6 +185,7 @@ export function RepositoryPane({ ), translate('auto.components.settings.repository.search.094adbe930', 'Default Worktree Base'), translate('auto.components.settings.repository.search.443d127b5a', 'Worktree Location'), + translate('auto.components.settings.repository.search.externalWorktrees', 'External worktrees'), translate('auto.components.settings.repository.search.projectRuntime', 'Project Runtime'), translate('auto.components.settings.repository.search.c5266c2c9d', 'Remove Project') ]) @@ -347,8 +367,14 @@ export function RepositoryPane({ + fetchWorktrees(repoId, { + executionHostId: selectedHostId, + requireAuthoritative: true + }) + } forceVisible={forceFullPaneForRepoMatch} /> diff --git a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx index 03164eeb261..a01d7d99ffe 100644 --- a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx @@ -4,6 +4,7 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../../../shared/repo-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection' vi.mock('../../store', () => ({ @@ -15,6 +16,28 @@ vi.mock('./BaseRefPicker', () => ({ BaseRefPicker: () => null })) +vi.mock('../ui/select', () => ({ + Select: ({ + value, + onValueChange, + children + }: { + value: string + onValueChange: (value: string) => void + children: React.ReactNode + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => ( + + ) +})) + const BASE_REPO: Repo = { id: 'repo-1', path: '/home/user/project', @@ -39,13 +62,21 @@ afterEach(() => { container.remove() }) -function render(repo: Repo, updateRepo: (repoId: string, updates: object) => void): void { +function render( + repo: Repo, + updateRepo: (repoId: string, updates: object) => void | Promise, + options: { + settings?: Pick | null + refreshRepo?: (repoId: string) => void | Promise + } = {} +): void { act(() => { root.render( React.createElement(RepositoryWorktreeDefaultsSection, { repo, - settings: null, + settings: options.settings ?? null, updateRepo, + refreshRepo: options.refreshRepo ?? (() => {}), forceVisible: true }) ) @@ -141,3 +172,54 @@ describe('RepositoryWorktreeDefaultsSection — worktree path', () => { expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined }) }) }) + +describe('RepositoryWorktreeDefaultsSection — external visibility', () => { + it('shows the inherited effective value without stamping the repo', () => { + render(BASE_REPO, vi.fn(), { + settings: { + workspaceDir: '/home/user/orca/workspaces', + worktreeVisibilityDefaults: { external: 'show' } + } + }) + + expect(container.textContent).toContain('Using global: Show') + expect(container.querySelector('select')?.value).toBe('global') + }) + + it('clears the override with the remote-safe null sentinel and refreshes classification', async () => { + const updateRepo = vi.fn().mockResolvedValue(true) + const refreshRepo = vi.fn().mockResolvedValue(true) + render({ ...BASE_REPO, externalWorktreeVisibility: 'show' }, updateRepo, { refreshRepo }) + + await act(async () => { + const select = container.querySelector('select')! + select.value = 'global' + select.dispatchEvent(new Event('change', { bubbles: true })) + }) + + expect(updateRepo).toHaveBeenCalledWith('repo-1', { externalWorktreeVisibility: null }) + expect(refreshRepo).toHaveBeenCalledWith('repo-1') + }) + + it('falls back to the effective explicit value when an older host rejects inheritance', async () => { + const updateRepo = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + render( + { + ...BASE_REPO, + externalWorktreeVisibility: 'show', + externalWorktreeVisibilityLegacy: false + }, + updateRepo + ) + + await act(async () => { + const select = container.querySelector('select')! + select.value = 'global' + select.dispatchEvent(new Event('change', { bubbles: true })) + }) + + expect(updateRepo).toHaveBeenNthCalledWith(2, 'repo-1', { + externalWorktreeVisibility: 'show' + }) + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx index 1aaa4662a4c..afd07379f19 100644 --- a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx @@ -2,18 +2,29 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' import { Button } from '../ui/button' import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { BaseRefPicker } from './BaseRefPicker' import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput' import { SearchableSetting } from './SearchableSetting' import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { + effectiveExternalWorktreeVisibility, + isLegacyRepoForExternalWorktreeVisibility +} from '../../../../shared/external-worktree-visibility' -type RepositoryWorktreeDefaultsUpdate = Pick +type RepositoryWorktreeDefaultsUpdate = Pick & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null +} type RepositoryWorktreeDefaultsSectionProps = { repo: Repo - settings: Pick | null - updateRepo: (repoId: string, updates: Partial) => void + settings: Pick | null + updateRepo: ( + repoId: string, + updates: Partial + ) => void | Promise + refreshRepo: (repoId: string) => void | Promise forceVisible: boolean } @@ -21,10 +32,96 @@ export function RepositoryWorktreeDefaultsSection({ repo, settings, updateRepo, + refreshRepo, forceVisible }: RepositoryWorktreeDefaultsSectionProps): React.JSX.Element { + const isLegacyRepo = isLegacyRepoForExternalWorktreeVisibility(repo) + const globalVisibility = + settings?.worktreeVisibilityDefaults?.external ?? (isLegacyRepo ? 'show' : 'hide') + const effectiveVisibility = effectiveExternalWorktreeVisibility( + repo, + isLegacyRepo, + settings?.worktreeVisibilityDefaults + ) + const updateVisibility = async (value: string): Promise => { + const visibility = value === 'global' ? null : value === 'show' ? 'show' : 'hide' + let updated = await updateRepo(repo.id, { externalWorktreeVisibility: visibility }) + if (updated === false && visibility === null) { + updated = await updateRepo(repo.id, { externalWorktreeVisibility: effectiveVisibility }) + } + if (updated !== false) { + await refreshRepo(repo.id) + } + } return ( <> + +
+
+ +

+ {repo.externalWorktreeVisibility === undefined + ? translate( + 'auto.components.settings.RepositoryPane.externalWorktreesInherited', + 'Using global: {{value0}}', + { + value0: + effectiveVisibility === 'show' + ? translate('auto.components.settings.RepositoryPane.show', 'Show') + : translate('auto.components.settings.RepositoryPane.hide', 'Hide') + } + ) + : translate( + 'auto.components.settings.RepositoryPane.externalWorktreesGlobal', + 'Global default: {{value0}}', + { + value0: + globalVisibility === 'show' + ? translate('auto.components.settings.RepositoryPane.show', 'Show') + : translate('auto.components.settings.RepositoryPane.hide', 'Hide') + } + )} +

+
+ +
+
+ [ ...translateSearchKeyword('auto.components.settings.general.search.df10666259', 'worktree') ] }, + { + title: translate( + 'auto.components.settings.general.search.externalWorktrees', + 'External worktrees' + ), + description: translate( + 'auto.components.settings.general.search.externalWorktreesDescription', + 'Choose whether worktrees created outside Orca appear by default.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.general.search.externalKeyword', + 'external' + ), + ...translateSearchKeyword('auto.components.settings.general.search.visibility', 'visibility'), + ...translateSearchKeyword('auto.components.settings.general.search.sidebar', 'sidebar'), + ...translateSearchKeyword('auto.components.settings.general.search.df10666259', 'worktree') + ] + }, { title: translate('auto.components.settings.general.search.141f71c69f', 'Nest Workspaces'), description: translate( diff --git a/src/renderer/src/components/settings/repository-git-worktree-search-entries.ts b/src/renderer/src/components/settings/repository-git-worktree-search-entries.ts index 46d6e3ff529..c3a7c5b5b3a 100644 --- a/src/renderer/src/components/settings/repository-git-worktree-search-entries.ts +++ b/src/renderer/src/components/settings/repository-git-worktree-search-entries.ts @@ -5,6 +5,28 @@ import { translateSearchKeyword } from './settings-search-keywords' export function getRepositoryGitWorktreeSearchEntries(repo: Repo): SettingsSearchEntry[] { return [ + { + title: translate( + 'auto.components.settings.repository.search.externalWorktrees', + 'External worktrees' + ), + description: translate( + 'auto.components.settings.repository.search.externalWorktreesDescription', + 'Override whether worktrees created outside Orca appear for this project.' + ), + keywords: [ + repo.displayName, + ...translateSearchKeyword( + 'auto.components.settings.repository.search.external', + 'external' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.visibility', + 'visibility' + ), + ...translateSearchKeyword('auto.components.settings.repository.search.sidebar', 'sidebar') + ] + }, { title: translate( 'auto.components.settings.repository.search.094adbe930', diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx index 3afe412fb8f..3701737c85e 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx @@ -35,10 +35,7 @@ function setState(overrides: Record = {}): void { setHideDetachedHeadWorkspaces: vi.fn(), hideWorkspacesFromOtherDevices: false, setHideWorkspacesFromOtherDevices: vi.fn(), - worktreesByRepo: {}, - folderWorkspaces: [], runtimeEnvironments: [], - runtimeStatusByEnvironmentId: new Map(), alwaysShowDefaultBranchWorkspace: true, setAlwaysShowDefaultBranchWorkspace: vi.fn(), ...overrides @@ -90,37 +87,17 @@ describe('SidebarWorkspaceFilterSection', () => { expect(rowLabels()).not.toContain(EXEMPTION_LABEL) }) - it('shows the other-client filter only when it can hide a known workspace', () => { + it('shows the other-client filter when a remote server is configured', () => { setState({ - worktreesByRepo: { - repo: [ - { - id: 'other', - runtimeOwnerEnvironmentId: 'server', - creatorProvenance: { kind: 'paired-device', deviceId: 'other-client' } - } - ] - }, - runtimeEnvironments: [{ id: 'server', pairedDeviceId: 'this-client' }] + runtimeEnvironments: [{ id: 'server' }] }) render() expect(rowLabels()).toContain('Hide other-client workspaces') }) - it('hides the other-client filter when every known workspace belongs to this client', () => { - setState({ - worktreesByRepo: { - repo: [ - { - id: 'own', - runtimeOwnerEnvironmentId: 'server', - creatorProvenance: { kind: 'paired-device', deviceId: 'this-client' } - } - ] - }, - runtimeEnvironments: [{ id: 'server', pairedDeviceId: 'this-client' }] - }) + it('hides the other-client filter for local-only clients', () => { + setState() render() expect(rowLabels()).not.toContain('Hide other-client workspaces') diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx index 4186da051bb..ba06a2d1dae 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React from 'react' import { CalendarClock, GitBranch, @@ -9,13 +9,7 @@ import { } from 'lucide-react' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' -import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' import { FilterToggleRow } from './FilterToggleRow' -import { - getPairedDeviceIdsByEnvironment, - isFolderWorkspaceFromOtherDevice, - isWorkspaceFromOtherDevice -} from './workspace-creator-visibility' const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilterSection() { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) @@ -32,41 +26,12 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) const setHideWorkspacesFromOtherDevices = useAppStore((s) => s.setHideWorkspacesFromOtherDevices) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) - const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const setAlwaysShowDefaultBranchWorkspace = useAppStore( (s) => s.setAlwaysShowDefaultBranchWorkspace ) - const showOtherClientFilter = useMemo(() => { - if (hideWorkspacesFromOtherDevices) { - return true - } - const pairedDeviceIds = getPairedDeviceIdsByEnvironment( - runtimeEnvironments, - runtimeStatusByEnvironmentId - ) - const worktrees = getIndexedAllWorktrees(worktreesByRepo) - return ( - worktrees.some( - (worktree) => !worktree.isArchived && isWorkspaceFromOtherDevice(worktree, pairedDeviceIds) - ) || - folderWorkspaces.some((workspace) => { - if (workspace.isArchived) { - return false - } - return isFolderWorkspaceFromOtherDevice(workspace, pairedDeviceIds) - }) - ) - }, [ - folderWorkspaces, - hideWorkspacesFromOtherDevices, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - worktreesByRepo - ]) + const showOtherClientFilter = runtimeEnvironments.length > 0 || hideWorkspacesFromOtherDevices return ( <> diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index 756c99d8522..a8c6045463d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -8,7 +8,7 @@ import type { Repo } from '../../../../shared/repo-types' import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import type * as WorktreeListModule from './WorktreeList' +import { getPinnedWorktreeRevealCollapsedGroupKeys } from './worktree-list/sidebar-row-reveal-ancestors' import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' const mockStore = vi.hoisted(() => ({ @@ -21,7 +21,6 @@ type WorktreeListComponent = React.ComponentType<{ }> let WorktreeList: WorktreeListComponent -let getPinnedWorktreeRevealCollapsedGroupKeys: typeof WorktreeListModule.getPinnedWorktreeRevealCollapsedGroupKeys function makeFolderWorkspacePathStatusMockState(): Record { return { @@ -828,7 +827,6 @@ describe('WorktreeList lineage child card renderer', () => { beforeAll(async () => { const module = await import('./WorktreeList') WorktreeList = module.default as WorktreeListComponent - getPinnedWorktreeRevealCollapsedGroupKeys = module.getPinnedWorktreeRevealCollapsedGroupKeys }, 60_000) it('renders project group headers when repos import before worktree rows load', async () => { diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 138f36a3a2f..dbbee941a4f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -1,5208 +1,42 @@ -/* eslint-disable max-lines */ -import React, { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffect } from 'react' -import { toast } from 'sonner' -import { - measureElement as measureVirtualElementSize, - useVirtualizer -} from '@tanstack/react-virtual' -import type { Range } from '@tanstack/react-virtual' -import { - AlertTriangle, - ChevronDown, - CircleX, - Ellipsis, - Eye, - FolderInput, - FolderPlus, - FolderX, - Loader2, - Plus, - Server, - ServerOff, - Shapes, - SlidersHorizontal, - Trash2 -} from 'lucide-react' +import React, { useCallback, useMemo } from 'react' import { useAppStore } from '@/store' -import { createLineageToggleHandlerCache } from './worktree-lineage-toggle-handler-cache' -import { reuseArrayIfEqual } from './worktree-agent-row-selectors' import { useShallow } from 'zustand/react/shallow' -import type { AppState } from '@/store/types' import { - getAllWorktreesFromState, useAllWorktrees, useProjectHostSetupProjection, useRepoMap, useWorktreeMap } from '@/store/selectors' -import WorktreeCard, { type ActiveSurfaceVariant } from './WorktreeCard' -import { WorktreeSidebarDropIndicator } from './WorktreeSidebarDropIndicator' -import { - getProjectGroupHeaderSectionEndByGroupId, - getRepoHeaderSectionEndByRepoId -} from './worktree-header-section-boundaries' -import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' -import { PendingWorktreeRow } from './PendingWorktreeRow' -import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { cn } from '@/lib/utils' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { ProjectGroup } from '../../../../shared/project-group-types' import type { Repo } from '../../../../shared/repo-types' -import type { ProjectOrderBy } from '../../../../shared/ui-chrome-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' -import type { - WorkspaceStatus, - WorkspaceStatusDefinition, - Worktree -} from '../../../../shared/worktree/types' -import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' -import { buildWorktreeComparator, compareWorktreeSortLabel } from './smart-sort' -import { - buildAttentionByWorktree, - hasFreshAttributedAgentStatus, - type SmartClass, - type WorktreeAttention -} from './smart-attention' -import { track } from '@/lib/telemetry' -import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' -import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' -import { - type Row, - type ProjectGroupingModel, - type WorktreeGroupBy, - ALL_GROUP_KEY, - PINNED_GROUP_KEY, - buildRows, - getProjectGroupHeaderKey, - getGroupKeysForWorktree, - getLineageGroupKey, - getPinnedWorktreeDisplayPolicy, - type PinnedWorktreeDisplayPolicy -} from './worktree-list-groups' -import { - buildLineageRowRekeyMap, - estimateRenderRowSize, - extractWorktreeVirtualRowIndexes, - getActiveStickyIndexesForScroll, - getRenderRowKey, - getStickyHeaderIndexes, - getVirtualRowTransform, - pruneStaleVirtualRowElementCache, - shouldUseHeaderTopSpacing, - WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, - type RenderRow -} from './worktree-list-virtual-rows' -import { - revealElementInScrollContainer, - WORKTREE_SIDEBAR_REVEAL_TOP_INSET -} from './worktree-sidebar-reveal' -import { - createPendingRevealScroll, - isRevealScrollSettling, - type PendingRevealScroll -} from './worktree-sidebar-reveal-scroll-settle' -import { - getWorkspaceStatus, - getWorkspaceStatusFromGroupKey, - getWorkspaceStatusGroupKey, - hasWorkspaceDragData, - readWorkspaceDragDataIds -} from './workspace-status' -import { useWorkspaceStatusDocumentDrop } from './use-workspace-status-drop' -import { - computeClearFilterActions, - computeVisibleWorktreeIds, - setVisibleWorktreeIds, - sidebarHasActiveFilters -} from './visible-worktrees' -import { - EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, - filterFolderWorkspacesFromOtherDevices, - getPairedDeviceIdsByEnvironment -} from './workspace-creator-visibility' -import { - getCyclicProjectedWorktreeLineageIds, - getWorktreeLineageAncestors -} from './worktree-lineage-projection' -import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' -import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos' -import { - getVisibleWorktreeBrowserActivityTabs, - getVisibleWorktreeTerminalActivityTabs -} from './visible-worktree-activity-inputs' -import { selectWorktreeListReviewCacheInputs } from './worktree-list-review-cache-inputs' -import { - VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, - useVirtualizedScrollAnchor, - type VirtualizedScrollAnchor -} from '@/hooks/useVirtualizedScrollAnchor' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry' -import { useWorktreeListScrollToTop } from './use-worktree-list-scroll-to-top' -import { WorktreeListScrollToTopButton } from './WorktreeListScrollToTopButton' -import { - getFolderWorkspacePathStatusDescription, - getFolderWorkspacePathStatusTitle -} from '@/lib/folder-workspace-path-status' -import { getShortcutPlatform } from '@/lib/shortcut-platform' -import { - SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, - type ScrollToCurrentWorkspaceRevealRequestDetail -} from '@/lib/scroll-to-current-workspace-status' -import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' -import { - getLogicalRepoOrderRankById, - getSidebarOrderedRepoHeaderIdsByBucket -} from './project-header-drop' -import { useProjectGroupHeaderDrag } from './project-group-header-drag' -import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop' -import { - buildManualOrderUpdatesForGroupDrop, - buildManualOrderUpdatesForVisibleGroups, - expandDraggedWorktreeIdsForVisibleLineage, - shouldWriteManualOrderForGroupDrop, - type WorktreeDragGroup -} from './worktree-manual-order' -import { - buildWorkspaceKanbanSidebarDropUpdates, - clearWorkspaceKanbanSidebarDropTargetVisual, - getWorkspaceKanbanSidebarDropGroups, - getWorkspaceKanbanSidebarDropTarget, - hasWorkspaceKanbanSidebarDropBoard, - isWorkspaceKanbanSidebarDropPointInBoard, - resolveWorkspaceKanbanSidebarFullLaneDropIndex, - updateWorkspaceKanbanSidebarDropTargetVisual -} from './workspace-kanban-sidebar-drop' -import { - resolveWorkspaceKanbanCardDropCommitTarget, - type WorkspaceKanbanCardTrackedDropTarget -} from './workspace-kanban-card-pointer-drag-dom' -import { - getFullDropIndexForWorktreeDragUnit, - getWorktreeDragUnitGroups -} from './worktree-drag-units' -import { - createSidebarDragPreview, - isSidebarPointerDragBlocked, - setSidebarPointerDragDocumentStyles, - updateSidebarDragPreviewPosition -} from './worktree-sidebar-pointer-drag-dom' -import { - getWorktreeSidebarDragAutoscroll, - getWorktreeSidebarDragRectsForGroup, - refreshWorktreeSidebarDragSession, - type WorktreeSidebarDragRect, - type WorktreeSidebarDragSession, - type WorktreeSidebarDragPoint -} from './worktree-sidebar-drag-autoscroll' -import { - getWorktreeSidebarDragGrab, - shouldReevaluateWorktreeSidebarDropAnchor, - type WorktreeSidebarDragGrab, - type WorktreeSidebarDropAnchor -} from './worktree-sidebar-drag-geometry' -import { - computeWorktreeSidebarDropPreview, - resolveWorktreeSidebarStatusDropCommitTarget, - type WorktreeSidebarStatusDropTarget, - type WorktreeSidebarTrackedStatusDropTarget, - type WorktreeSidebarDropPreview -} from './worktree-sidebar-drop-preview' -import { - getReorderedWorktreeIdsToUnnest, - getWorktreeLineageDropTargetId -} from './worktree-lineage-drag-drop' -import { resolveProjectGroupHeaderColor } from './project-header-color' -import { - REPO_HEADER_ACTION_BUTTON_CLASS, - REPO_HEADER_ACTION_REVEAL_CLASS -} from './repo-header-action-button-class' -import { - areWorktreeSelectionsEqual, - getWorktreeSelectionIntent, - pruneWorktreeSelection, - updateWorktreeSelection -} from './worktree-multi-selection' -import { persistWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-persistence' import { getRepoExecutionHostId, - getSettingsFocusedExecutionHostId, - getWorktreeExecutionHostId, - type ExecutionHostId + getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' -import { getRepoHeaderCreateState } from './repo-header-create-state' -import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' -import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets' -import { keybindingMatchesAction } from '../../../../shared/keybindings' -import { ProjectGroupNameDialog } from './ProjectGroupNameDialog' -import { ProjectGroupDeleteDialog } from './ProjectGroupDeleteDialog' -import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets' -import { findRepoForHost } from '@/store/slices/repo-host-identity' -import { isGitRepoKind } from '../../../../shared/repo-kind' +import { getActiveSidebarWorkspaceId } from '../../../../shared/workspace-scope' +import { getPinnedWorktreeDisplayPolicy } from './worktree-list-groups' +import { selectWorktreeListReviewCacheInputs } from './worktree-list-review-cache-inputs' +import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' +import { SidebarWorktreeListDialogs } from './worktree-list/SidebarWorktreeListDialogs' +import { SidebarWorktreeListEmptyState } from './worktree-list/SidebarWorktreeListEmptyState' +import { VirtualizedWorktreeViewport } from './worktree-list/VirtualizedWorktreeViewport' +import { markSidebarWorktreeActiveImmediately } from './worktree-list/worktree-option-dom' import { - effectiveExternalWorktreeVisibility, - isLegacyRepoForExternalWorktreeVisibility -} from '../../../../shared/worktree/ownership' -import { RepoIconGlyph } from '@/components/repo/repo-icon' -import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' -import ImportedWorktreesVisibilityLine from './ImportedWorktreesVisibilityLine' -import NewExternalWorktreesInboxLine from './NewExternalWorktreesInboxLine' -import SuppressExternalWorktreeInboxDialog from './SuppressExternalWorktreeInboxDialog' -import { - keepImportedWorktreesHiddenCard, - IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR, - showImportedWorktreesCard, - type ImportedWorktreeCardActionState -} from './imported-worktrees-card-actions' -import { - suppressNewExternalWorktreeInbox, - type NewExternalWorktreesInboxActionState -} from './new-external-worktrees-inbox-actions' -import { isEligibleWorktreeParent } from './worktree-parent-candidates' -import { - buildImportedWorktreesCardCandidates, - getHiddenImportedWorktrees -} from './imported-worktrees-card-candidates' -import { buildNewExternalWorktreesInboxCandidates } from './new-external-worktrees-inbox-candidates' -import { - WORKTREE_SECTION_HEADER_PADDING_LEFT, - LINEAGE_CHILDREN_INLINE_OFFSET, - getFolderBackedRepoWorktreeCardContentIndent, - getFolderBackedRepoWorktreeCardSurfaceInset, - getFolderWorkspaceRowGeometry, - getLineageChildrenInlineStyle, - getLineageNestedRowGeometry, - getProjectGroupHeaderPaddingLeft, - getWorktreeCardContentIndent, - getWorktreeCardSurfaceInset -} from './worktree-list-indentation' -import { addHostSectionRows, type HostHeaderRow, type HostSectionRow } from './host-section-rows' -import { orderHostSectionOptions } from './host-section-order' -import { useHostHeaderDrag } from './host-header-drag' -import { buildSidebarHostOptions } from './sidebar-host-options' -import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' -import { ProjectHeaderActions } from './ProjectHeaderActions' -import { translate } from '@/i18n/i18n' -import { folderWorkspaceKey, getActiveSidebarWorkspaceId } from '../../../../shared/workspace-scope' -import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' -import { - isConfirmedStaleFolderPathStatus, - type FolderWorkspacePathStatus -} from '../../../../shared/folder-workspace-path-status' -import { - getFolderWorkspaceRevealGroupKeys, - getKnownSidebarWorktreeById, - sidebarWorkspaceStillExists -} from './worktree-list-folder-reveal' -import { - filterFolderWorkspacesForVisibleHosts, - filterProjectGroupsForVisibleHosts, - getFolderPathStatusRouteOptionsForRows, - getVisibleSidebarHostIdSet -} from './worktree-list-host-filtering' -import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' -import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference' -import { getCyclableWorktreeIds, resolveCycledWorktreeId } from './worktree-keyboard-cycle' - -export { - getScrollTopToRevealBounds, - WORKTREE_SIDEBAR_REVEAL_TOP_INSET -} from './worktree-sidebar-reveal' - -type ProjectGroupNameDialogState = - | { type: 'create-from-repo'; repo: Repo } - | { type: 'rename'; groupId: string; currentName: string } - -type ProjectGroupDeleteDialogState = { - groupId: string - groupName: string - removeContainedProjects: boolean -} - -// Why: epoch-driven recomputes often produce arrays whose contents and order are unchanged; reusing the previous identity when element-wise equal keeps downstream memos and React.memo'd cards bailing out. Safe only because elements (Worktree objects / id strings) are immutably REPLACED on change — never wrap arrays of mutated-in-place objects. -function useReusedArrayIdentity(next: T[]): T[] { - const previousRef = useRef(next) - const result = reuseArrayIfEqual(previousRef.current, next) - previousRef.current = result - return result -} - -// Debounce re-sort after a sortEpoch bump so background score changes don't jar row positions. -const SORT_SETTLE_MS = 3_000 -const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500 -const EMPTY_PROJECT_GROUPS: readonly ProjectGroup[] = [] -const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} -const EMPTY_WORKTREE_ID_SET: ReadonlySet = new Set() -const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} -const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} -const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} -const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} -const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300 -const NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK = (): void => {} -const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { - // Why: TanStack Virtual owns scroll correction; native overflow anchoring fights it and causes jumps. - overflowAnchor: 'none' -} - -const recordKeyCountCache = new WeakMap, number>() - -export function countRecordKeysByReference(record: Record): number { - const cached = recordKeyCountCache.get(record) - if (cached !== undefined) { - return cached - } - const count = Object.keys(record).length - recordKeyCountCache.set(record, count) - return count -} - -export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: { - isScrolling: boolean - now: number - suppressUntil: number -}): boolean { - return !args.isScrolling && args.now >= args.suppressUntil -} - -export function resolvePendingSidebarReveal(args: { - targetIndex: number - targetWorktreeStillExists: boolean -}): 'scroll-and-clear' | 'clear' | 'keep-pending' { - if (args.targetIndex !== -1) { - return 'scroll-and-clear' - } - return args.targetWorktreeStillExists ? 'keep-pending' : 'clear' -} - -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false - } - - // xterm's hidden input textarea isn't a real text field; treating it as one would block sidebar shortcuts. - if (target.classList.contains('xterm-helper-textarea')) { - return false - } - - if (target.isContentEditable) { - return true - } - - return ( - target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== - null - ) -} - -function stopRepoHeaderKeyboardToggle(event: React.KeyboardEvent): void { - if (event.key === 'Enter' || event.key === ' ') { - event.stopPropagation() - } -} - -function stopNestedWorktreeCardBubble(event: React.SyntheticEvent): void { - event.stopPropagation() -} - -function handleRepoHeaderActionPointerDown(event: React.PointerEvent): void { - event.stopPropagation() -} - -function handleRepoHeaderCollapseAffordancePointerDown( - event: React.PointerEvent -): void { - // Why: keep collapse-chevron clicks from arming the repo-header row drag. - event.stopPropagation() -} - -function stopRepoHeaderMenuEvent(event: React.SyntheticEvent): void { - event.stopPropagation() -} - -function shouldIgnoreRepoHeaderToggle(event: React.SyntheticEvent): boolean { - return isRepoHeaderActionTarget(event.target, event.currentTarget) -} - -function getWorktreeOptionId(rowKey: string): string { - return `worktree-list-option-${encodeURIComponent(rowKey)}` -} - -function getMountedWorktreeOptions(worktreeId: string, root?: ParentNode | null): HTMLElement[] { - const scope = root ?? document - const result: HTMLElement[] = [] - scope.querySelectorAll('[data-worktree-id]').forEach((element) => { - if (element.dataset.worktreeId === worktreeId) { - result.push(element) - } - }) - return result -} - -function markSidebarWorktreeActiveImmediately(worktreeId: string, primaryRowKey?: string): void { - const sidebar = document.querySelector('[data-worktree-sidebar]') - const nextOptions = getMountedWorktreeOptions(worktreeId, sidebar) - const nextOption = nextOptions[0] - if (!nextOption) { - return - } - - sidebar - ?.querySelectorAll('[role="option"][aria-current="page"]') - .forEach((option) => option.removeAttribute('aria-current')) - - for (const option of nextOptions) { - option.setAttribute('aria-current', 'page') - } - sidebar - ?.querySelectorAll('[data-worktree-card-surface][data-worktree-card-active]') - .forEach((surface) => { - if (!nextOptions.some((option) => option.contains(surface))) { - surface.removeAttribute('data-worktree-card-active') - } - }) - for (const option of nextOptions) { - const activeSurfaceVariant = - primaryRowKey !== undefined - ? option.dataset.worktreeRowKey === primaryRowKey - ? 'primary' - : 'secondary' - : option === nextOption - ? 'primary' - : 'secondary' - const surface = option.matches('[data-worktree-card-surface]') - ? option - : option.querySelector('[data-worktree-card-surface]') - surface?.setAttribute('data-worktree-card-active', activeSurfaceVariant) - } -} - -function revealMountedWorktreeElement( - container: HTMLElement, - worktreeId: string, - behavior: ScrollBehavior, - optionId?: string, - onScrollIssued?: (targetTop: number) => void -): HTMLElement | null { - const element = optionId - ? document.getElementById(optionId) - : getMountedWorktreeOptions(worktreeId, container)[0] - if (!element || !container.contains(element)) { - return null - } - return revealElementInScrollContainer(container, element, behavior, onScrollIssued) - ? element - : null -} - -function revealMountedSidebarRowElement( - container: HTMLElement, - rowKey: string, - behavior: ScrollBehavior, - onScrollIssued?: (targetTop: number) => void -): HTMLElement | null { - const element = document.getElementById(getWorktreeOptionId(rowKey)) - if (!element || !container.contains(element)) { - return null - } - return revealElementInScrollContainer(container, element, behavior, onScrollIssued) - ? element - : null -} - -function getRenderRowSidebarKey(row: RenderRow): string | null { - if (row.type === 'header') { - return row.key - } - if (row.type === 'item') { - return row.rowKey - } - if (row.type === 'folder-workspace') { - return folderWorkspaceKey(row.folderWorkspace.id) - } - if (row.type === 'pending-creation') { - return `pending:${row.creationId}` - } - if (row.type === 'imported-worktrees-card') { - return row.key - } - if (row.type === 'new-external-worktrees-inbox') { - return row.key - } - return null -} - -function rowKeyMatchesRenderRow(row: RenderRow, rowKey: string): boolean { - if (row.type === 'lineage-group') { - return row.rows.some((item) => item.rowKey === rowKey) - } - return getRenderRowSidebarKey(row) === rowKey -} - -function getProjectIdFromHeaderRowKey(rowKey: string): string | null { - if (!rowKey.startsWith('project:')) { - return null - } - const withoutPrefix = rowKey.slice('project:'.length) - const setupSeparator = withoutPrefix.indexOf('::setup:') - return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) -} - -function getRepoIdsFromHeaderRowKey( - rowKey: string, - repoMap: Map, - projectGrouping?: ProjectGroupingModel -): string[] { - if (rowKey.startsWith('repo:')) { - return [rowKey.slice('repo:'.length)] - } - const setupMarker = '::setup:' - const setupIndex = rowKey.indexOf(setupMarker) - if (rowKey.startsWith('project:') && setupIndex !== -1) { - return [rowKey.slice(setupIndex + setupMarker.length)] - } - const projectId = getProjectIdFromHeaderRowKey(rowKey) - if (!projectId) { - return [] - } - const repoIds = new Set() - for (const setup of projectGrouping?.projectHostSetups ?? []) { - if (setup.projectId === projectId && repoMap.has(setup.repoId)) { - repoIds.add(setup.repoId) - } - } - const project = projectGrouping?.projects.find((candidate) => candidate.id === projectId) - for (const repoId of project?.sourceRepoIds ?? []) { - if (repoMap.has(repoId)) { - repoIds.add(repoId) - } - } - return [...repoIds] -} - -function getProjectGroupAncestorKeys( - projectGroupId: string | null | undefined, - projectGroups: readonly ProjectGroup[] -): string[] { - const groupsById = new Map(projectGroups.map((group) => [group.id, group])) - const keys: string[] = [] - const seen = new Set() - let currentGroupId = projectGroupId ?? null - while (currentGroupId && !seen.has(currentGroupId)) { - const group = groupsById.get(currentGroupId) - if (!group) { - break - } - seen.add(currentGroupId) - keys.unshift(getProjectGroupHeaderKey(group.id)) - currentGroupId = group.parentGroupId - } - return keys -} - -function getSidebarRowRevealAncestorKeys(args: { - rowKey: string - repoMap: Map - projectGroups: readonly ProjectGroup[] - projectGrouping?: ProjectGroupingModel -}): string[] { - if (args.rowKey.startsWith('project-group:')) { - const groupId = args.rowKey.slice('project-group:'.length) - const group = args.projectGroups.find((candidate) => candidate.id === groupId) - return getProjectGroupAncestorKeys(group?.parentGroupId, args.projectGroups) - } - const keys = new Set() - for (const repoId of getRepoIdsFromHeaderRowKey( - args.rowKey, - args.repoMap, - args.projectGrouping - )) { - const repo = args.repoMap.get(repoId) - for (const key of getProjectGroupAncestorKeys(repo?.projectGroupId, args.projectGroups)) { - keys.add(key) - } - } - return [...keys] -} - -function getWorktreeVisibilityMenuLabel(repo: Repo): string { - const visibility = effectiveExternalWorktreeVisibility( - repo, - isLegacyRepoForExternalWorktreeVisibility(repo) - ) - return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees' -} - -const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4 -type VirtualizedWorktreeViewportProps = { - rows: HostSectionRow[] - activeWorktreeId: string | null - currentWorktreeId: string | null - groupBy: WorktreeGroupBy - pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy - projectOrderBy: ProjectOrderBy - toggleGroup: (key: string) => void - collapsedGroups: Set - handleCreateForRepo: (projectId: string) => void - handleOpenRepoSettings: (projectId: string, sectionId?: string) => void - handleOpenWorktreeVisibility: (repo: Repo) => void - handleShowImportedWorktrees: (projectId: string) => void - handleKeepImportedWorktreesHidden: (projectId: string) => void - importedWorktreeCardActionState: ReadonlyMap - handleOpenSuppressExternalWorktreeInbox: (projectId: string) => void - newExternalWorktreeInboxActionState: ReadonlyMap - handleRemoveProject: (repo: Repo) => void - handleCreateGroupFromRepo: (repo: Repo) => void - handleMoveProjectToGroup: (repo: Repo, groupId: string) => void - handleRemoveProjectFromGroup: (repo: Repo) => void - handleRenameProjectGroup: (groupId: string, currentName: string) => void - handleDeleteProjectGroup: (groupId: string, groupName: string) => void - handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void - activeModal: string - pendingRevealWorktree: PendingSidebarWorktreeReveal | null - pendingRevealSidebarRow: PendingSidebarRowReveal | null - clearPendingRevealWorktreeId: () => void - clearPendingRevealSidebarRow: () => void - agentSendTargetWorktreeId: string | null - worktrees: Worktree[] - folderWorkspaces: readonly FolderWorkspace[] - selectedWorktreeIds: ReadonlySet - selectedWorktrees: readonly Worktree[] - onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean - onImmediateWorktreeActivate: (worktreeId: string, rowKey: string | undefined) => void - onContextMenuSelect: ( - event: React.MouseEvent, - worktree: Worktree - ) => readonly Worktree[] - repoMap: Map - defaultHostId: ExecutionHostId - worktreeMap: Map - worktreeLineageById: Record - workspaceLineageByChildKey: Record - // Full canonical repo-id order; must include hidden repos or a reorder silently drops them. - allRepoIds: string[] - onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void - onHostDragActiveChange: (active: boolean) => void - prCache: AppState['prCache'] | null - hostedReviewCache: AppState['hostedReviewCache'] | null - workspaceStatuses: readonly WorkspaceStatusDefinition[] - projectGrouping?: ProjectGroupingModel - projectGroups?: readonly ProjectGroup[] - onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void - onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void - onMoveWorktreesToStatusAtIndex: (args: { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] - }) => void - onPinWorktree: (worktreeId: string) => void - onPinWorktrees: (worktreeIds: readonly string[]) => void - onDropWorktreesOnWorkspaceBoard: (args: { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] - }) => void - workspaceBoardOpen: boolean - onWorkspaceBoardDragPreviewStart: () => void - onWorkspaceBoardDragPreviewCommit: () => void - onWorkspaceBoardDragPreviewCancel: () => void - shouldShowWorkspaceBoardDropIndicator: ( - worktreeIds: readonly string[], - status: WorkspaceStatus - ) => boolean - onReorderWorktrees: (args: { - groups: readonly WorktreeDragGroup[] - sourceGroupKey: string - draggedIds: readonly string[] - dropIndex: number - }) => void - // Why: grouping remounts the viewport, add/delete stays mounted; bridge both so the virtualizer never resets to scrollTop 0. - scrollOffsetRef: React.MutableRefObject - scrollAnchorRef: React.MutableRefObject -} - -type WorktreeItemRow = Extract -type FolderWorkspaceItemRow = Extract - -function formatSectionActivityLabel(count: number, label: string): string { - return `${count} ${label}${count === 1 ? '' : 's'}` -} - -function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element { - const totalLabel = formatSectionActivityLabel(count, 'workspace') - - return ( - - - - - {count} - - - - {totalLabel} - - - - ) -} - -function HostHeaderHealthIcon({ - health -}: { - health: HostHeaderRow['health'] -}): React.JSX.Element | null { - // Why: only surface states needing attention; healthy is the silent default. - if (health === 'connecting') { - return - } - if (health === 'blocked' || health === 'error') { - return - } - return null -} - -function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } | null { - // Why: a blocked compatibility verdict earns a compact warning so the host stands out. - if (row.health === 'blocked') { - return { - text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'), - isWarning: true - } - } - // Why: auth-failed needs a worded status; the health icon alone doesn't tell the user to re-auth. - if (row.connectionStatus === 'auth-failed') { - return { - text: translate( - 'auto.components.sidebar.WorktreeList.hostAuthNeeded', - 'Authentication needed' - ), - isWarning: true - } - } - if (row.health === 'disconnected') { - return { - text: translate('auto.components.sidebar.WorktreeList.hostDisconnected', 'Disconnected'), - isWarning: false - } - } - // Why: show the transport detail only for remote hosts; it's noise under the local label. - if (row.kind !== 'local') { - return { text: row.detail, isWarning: false } - } - return null -} - -function HostSectionHeader({ - row, - onToggle, - onDragPointerDown, - dragging -}: { - row: HostHeaderRow - onToggle: () => void - onDragPointerDown?: (event: React.PointerEvent) => void - dragging?: boolean -}): React.JSX.Element { - const isBlocked = row.health === 'blocked' - const isDisconnected = row.health === 'disconnected' - const detail = getHostHeaderDetail(row) - return ( -
- {/* Why: outlined card + server glyph marks hosts as machines, not mere groups. */} -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onToggle() - } - }} - > - {isDisconnected ? ( - - ) : ( - - )} - - {/* Why: badge hugs the label (like repo headers) instead of floating by the hover controls. */} -
- - {row.label} - - {detail ? ( - - {detail.text} - - ) : null} - -
-
- -
- - - -
-
- ) -} - -function FolderPathStatusIndicator({ - status -}: { - status: FolderWorkspacePathStatus | null | undefined -}): React.JSX.Element | null { - const title = getFolderWorkspacePathStatusTitle(status) - if (!status || status.exists || !title) { - return null - } - const destructive = isConfirmedStaleFolderPathStatus(status) - return ( - - - - - - - -
-
{title}
-
- {getFolderWorkspacePathStatusDescription(status)} -
-
-
-
- ) -} - -type WorktreeRowDragState = { - draggingWorktreeId: string | null - sourceGroupKey: string | null - dropIndex: number | null - dropIndicatorY: number | null - previewOffsetsByWorktreeId: ReadonlyMap - pointerY: number | null -} - -const EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS: ReadonlyMap = new Map() - -const WORKTREE_ROW_DRAG_INITIAL_STATE: WorktreeRowDragState = { - draggingWorktreeId: null, - sourceGroupKey: null, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null -} - -type WorktreePointerDrag = { - pointerId: number - sourceRow: HTMLElement - startX: number - startY: number - currentX: number - currentY: number - worktreeId: string - draggedIds: readonly string[] - reorderDraggedIds: readonly string[] - reorderUnitDraggedIds: readonly string[] - sourceGroupKey: string - rects: readonly WorktreeSidebarDragRect[] - active: boolean - preview: HTMLElement | null - previewOffsetX: number - previewOffsetY: number - workspaceBoardDragPreviewRequested: boolean - frameId: number | null - latestBoardDropTarget: WorkspaceKanbanCardTrackedDropTarget | null - latestStatusDropTarget: WorktreeSidebarTrackedStatusDropTarget | null -} - -function areWorktreeDragPreviewOffsetsEqual( - a: ReadonlyMap, - b: ReadonlyMap -): boolean { - if (a === b) { - return true - } - if (a.size !== b.size) { - return false - } - for (const [key, value] of a) { - if (b.get(key) !== value) { - return false - } - } - return true -} - -function updateLatestWorktreeStatusDropTarget( - drag: WorktreePointerDrag, - target: WorktreeSidebarStatusDropTarget & { lineageParentId: string | null }, - preview: WorktreeSidebarDropPreview | null -): void { - drag.latestStatusDropTarget = - target.status || target.isPinDrop || target.lineageParentId - ? { - target, - preview, - x: drag.currentX, - y: drag.currentY - } - : null -} - -function getWorktreeVirtualRowTransform(start: number, previewOffset: number): string { - const base = getVirtualRowTransform(start) - return previewOffset === 0 ? base : `${base} translateY(${previewOffset}px)` -} - -function getPointerDropStatusTarget(args: { - container: HTMLElement - x: number - y: number -}): WorktreeSidebarStatusDropTarget & { lineageParentId: string | null } { - const target = document.elementFromPoint(args.x, args.y) - if (!(target instanceof Element) || !args.container.contains(target)) { - return { status: null, isPinDrop: false, lineageParentId: null } - } - const pinTarget = target.closest('[data-workspace-pin-drop-target]') - if (pinTarget && args.container.contains(pinTarget)) { - return { status: null, isPinDrop: true, lineageParentId: null } - } - const lineageParentId = getWorktreeLineageDropTargetId({ - container: args.container, - target, - pointerY: args.y - }) - const statusTarget = target.closest('[data-workspace-status-drop-target]') - return { - status: - statusTarget && args.container.contains(statusTarget) - ? ((statusTarget.dataset.workspaceStatus as WorkspaceStatus | undefined) ?? null) - : null, - isPinDrop: false, - lineageParentId - } -} - -function shouldPreferSidebarStatusDropTarget(args: { - sourceGroupKey: string - target: WorktreeSidebarStatusDropTarget - workspaceStatuses: readonly WorkspaceStatusDefinition[] -}): boolean { - if (args.target.isPinDrop) { - return true - } - if (!args.target.status) { - return false - } - const sourceStatus = getWorkspaceStatusFromGroupKey(args.sourceGroupKey, args.workspaceStatuses) - // Why: overlapping edge zones — the section under the pointer must win so guide and drop agree. - return sourceStatus !== null && args.target.status !== sourceStatus -} - -function isWorktreeItemRow(row: HostSectionRow): row is WorktreeItemRow { - return row.type === 'item' -} - -export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | null): boolean { - if (worktreeId === null) { - return false - } - if (row.type === 'folder-workspace') { - return folderWorkspaceKey(row.folderWorkspace.id) === worktreeId - } - if (row.type === 'lineage-group') { - return row.rows.some((item) => item.worktree.id === worktreeId) - } - return row.type === 'item' && row.worktree.id === worktreeId -} - -function isPinnedWorktreeRow(row: WorktreeItemRow): boolean { - return row.sectionKey === PINNED_GROUP_KEY -} - -function getRenderRowWorktreeItem(row: RenderRow, worktreeId: string): WorktreeItemRow | null { - if (row.type === 'lineage-group') { - return row.rows.find((item) => item.worktree.id === worktreeId) ?? null - } - return row.type === 'item' && row.worktree.id === worktreeId ? row : null -} - -function getRenderRowOptionId( - row: RenderRow | undefined, - worktreeId?: string | null -): string | undefined { - if (!row) { - return undefined - } - if (row.type === 'lineage-group') { - const targetRow = worktreeId ? row.rows.find((item) => item.worktree.id === worktreeId) : null - return getWorktreeOptionId((targetRow ?? row.rows[0])?.rowKey ?? row.key) - } - if (row.type === 'item') { - return getWorktreeOptionId(row.rowKey) - } - if (row.type === 'folder-workspace') { - return getWorktreeOptionId(folderWorkspaceKey(row.folderWorkspace.id)) - } - return undefined -} - -function getActiveDescendantOptionId(args: { - activeWorktreeId: string | null - primaryActiveRowKey?: string - pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy - renderRows: readonly RenderRow[] - virtualItems: readonly { index: number }[] -}): string | undefined { - if (args.activeWorktreeId === null) { - return undefined - } - if (args.primaryActiveRowKey) { - const primaryOptionId = getWorktreeOptionId(args.primaryActiveRowKey) - for (const item of args.virtualItems) { - const row = args.renderRows[item.index] - if (row && getRenderRowOptionId(row, args.activeWorktreeId) === primaryOptionId) { - return primaryOptionId - } - } - } - let fallbackOptionId: string | undefined - for (const item of args.virtualItems) { - const row = args.renderRows[item.index] - if (row && renderRowContainsWorktree(row, args.activeWorktreeId)) { - const optionId = getRenderRowOptionId(row, args.activeWorktreeId) - if (!optionId) { - continue - } - const itemRow = getRenderRowWorktreeItem(row, args.activeWorktreeId) - if ( - args.pinnedDisplayPolicy === 'duplicate-in-groups' && - itemRow && - !isPinnedWorktreeRow(itemRow) - ) { - return optionId - } - fallbackOptionId ??= optionId - } - } - return fallbackOptionId -} - -function findPreferredRenderRowIndexForWorktree( - renderRows: readonly RenderRow[], - worktreeId: string, - pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy -): number { - let fallbackIndex = -1 - for (let index = 0; index < renderRows.length; index++) { - const row = renderRows[index] - if (!renderRowContainsWorktree(row, worktreeId)) { - continue - } - if (fallbackIndex === -1) { - fallbackIndex = index - } - const itemRow = getRenderRowWorktreeItem(row, worktreeId) - if (pinnedDisplayPolicy === 'duplicate-in-groups' && itemRow && !isPinnedWorktreeRow(itemRow)) { - return index - } - } - return fallbackIndex -} - -export function getPinnedWorktreeRevealCollapsedGroupKeys({ - worktree, - collapsedGroups -}: { - worktree: Worktree - collapsedGroups: ReadonlySet -}): string[] { - if (!worktree.isPinned) { - return [] - } - const keys: string[] = [] - // Why: the reveal effect already opens this host; re-returning it would toggle it back closed. - if (collapsedGroups.has(PINNED_GROUP_KEY)) { - keys.push(PINNED_GROUP_KEY) - } - return keys -} - -function uniqueWorktreeIds(ids: readonly string[]): string[] { - return Array.from(new Set(ids)) -} - -function buildRenderableRows(rows: HostSectionRow[]): RenderRow[] { - const renderRows: RenderRow[] = [] - for (let index = 0; index < rows.length; index++) { - const row = rows[index] - if ( - !isWorktreeItemRow(row) || - row.lineageChildCount === 0 || - row.lineageCollapsed || - rows[index + 1]?.type !== 'item' || - (rows[index + 1] as WorktreeItemRow).depth <= row.depth - ) { - renderRows.push(row) - continue - } - - const groupRows: WorktreeItemRow[] = [row] - let cursor = index + 1 - while (cursor < rows.length) { - const child = rows[cursor] - if (!isWorktreeItemRow(child) || child.depth <= row.depth) { - break - } - groupRows.push(child) - cursor++ - } - renderRows.push({ - type: 'lineage-group', - key: `${row.sectionKey}:${getLineageGroupKey(row.worktree.id)}`, - rows: groupRows - }) - index = cursor - 1 - } - return renderRows -} - -// Why: getRenderRowKey lives with the other virtual-row helpers now; keep the -// long-standing import path working for callers that reach for it here. -export { getRenderRowKey } - -export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { - const groups: WorktreeDragGroup[] = [] - let current: { key: string; ids: string[] } | null = null - const naturalWorktreeIds = new Set( - rows.flatMap((row) => - row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] - ) - ) - - for (const row of rows) { - if (row.type === 'header') { - current = { key: row.key, ids: [] } - groups.push({ key: current.key, worktreeIds: current.ids }) - continue - } - if ( - row.type === 'host-header' || - row.type === 'imported-worktrees-card' || - row.type === 'new-external-worktrees-inbox' || - row.type === 'pending-creation' || - row.type === 'folder-workspace' - ) { - continue - } - if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { - continue - } - if (!current) { - current = { key: ALL_GROUP_KEY, ids: [] } - groups.push({ key: current.key, worktreeIds: current.ids }) - } - current.ids.push(row.worktree.id) - } - - return groups.filter((group) => group.worktreeIds.length > 0) -} - -export function canKeepImportedWorktreesHidden( - row: Extract, - actionState: ImportedWorktreeCardActionState | undefined -): boolean { - return row.placement === 'repo-group' && actionState?.forceVisible !== true -} - -export function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): { - groupKeyByRowKey: Map - groupIndexByRowKey: Map -} { - const groupKeyByRowKey = new Map() - const groupIndexByRowKey = new Map() - const groupIndexes = new Map() - const naturalWorktreeIds = new Set( - rows.flatMap((row) => - row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] - ) - ) - for (const row of rows) { - if (row.type === 'header') { - groupIndexes.set(row.key, 0) - continue - } - if (row.type !== 'item') { - continue - } - if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { - continue - } - const index = groupIndexes.get(row.sectionKey) ?? 0 - groupKeyByRowKey.set(row.rowKey, row.sectionKey) - groupIndexByRowKey.set(row.rowKey, index) - groupIndexes.set(row.sectionKey, index + 1) - } - return { groupKeyByRowKey, groupIndexByRowKey } -} - -function getVirtualRowIndex(element: Element): number | null { - const index = Number.parseInt(element.getAttribute('data-index') ?? '', 10) - return Number.isNaN(index) ? null : index -} - -function getVirtualRowKey(element: Element): string | null { - return element.getAttribute('data-worktree-virtual-row-key') -} - -const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport({ - rows, - activeWorktreeId, - currentWorktreeId, - groupBy, - pinnedDisplayPolicy, - projectOrderBy, - toggleGroup, - collapsedGroups, - handleCreateForRepo, - handleOpenRepoSettings, - handleOpenWorktreeVisibility, - handleShowImportedWorktrees, - handleKeepImportedWorktreesHidden, - importedWorktreeCardActionState, - handleOpenSuppressExternalWorktreeInbox, - newExternalWorktreeInboxActionState, - handleRemoveProject, - handleCreateGroupFromRepo, - handleMoveProjectToGroup, - handleRemoveProjectFromGroup, - handleRenameProjectGroup, - handleDeleteProjectGroup, - handleCreateFolderWorkspace, - activeModal, - pendingRevealWorktree, - pendingRevealSidebarRow, - clearPendingRevealWorktreeId, - clearPendingRevealSidebarRow, - agentSendTargetWorktreeId, - worktrees, - folderWorkspaces, - selectedWorktreeIds, - selectedWorktrees, - onSelectionGesture, - onImmediateWorktreeActivate, - onContextMenuSelect, - repoMap, - defaultHostId, - worktreeMap, - worktreeLineageById, - workspaceLineageByChildKey, - allRepoIds, - onReorderHostSections, - onHostDragActiveChange, - prCache, - hostedReviewCache, - workspaceStatuses, - projectGrouping, - projectGroups = EMPTY_PROJECT_GROUPS, - onMoveWorktreeToStatus, - onMoveWorktreesToStatus, - onMoveWorktreesToStatusAtIndex, - onPinWorktree, - onPinWorktrees, - onDropWorktreesOnWorkspaceBoard, - workspaceBoardOpen, - onWorkspaceBoardDragPreviewStart, - onWorkspaceBoardDragPreviewCommit, - onWorkspaceBoardDragPreviewCancel, - shouldShowWorkspaceBoardDropIndicator, - onReorderWorktrees, - scrollOffsetRef, - scrollAnchorRef -}: VirtualizedWorktreeViewportProps) { - const scrollRef = useRef(null) - // Why: callback-ref only mutates scrollRef; state re-runs the scroll-to-top listener attach. - const [scrollElement, setScrollElement] = useState(null) - const suppressMeasurementAdjustmentUntilRef = useRef(0) - const directScrollInputUntilRef = useRef(0) - const [dragOverStatus, setDragOverStatus] = useState(null) - const [pinDragOver, setPinDragOver] = useState(false) - const [nativeLineageDropTargetId, setNativeLineageDropTargetId] = useState(null) - const [worktreeDragState, setWorktreeDragState] = useState( - WORKTREE_ROW_DRAG_INITIAL_STATE - ) - const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) - const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) - const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) - const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) - const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) - const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) - const cyclicLineageIds = useMemo( - () => getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap), - [worktreeLineageById, worktreeMap] - ) - const worktreeDragSessionRef = useRef(null) - // Why: cross-group hovers hit-test a group the session never captured, so hold - // that group's drop decision separately or a card expanding in the target group - // moves the insertion line under a still pointer. - const statusDropAnchorsRef = useRef>(new Map()) - const worktreePointerDragRef = useRef(null) - const worktreePointerAutoscrollFrameIdRef = useRef(null) - const worktreePointerAutoscrollLastFrameTimeRef = useRef(null) - const worktreeNativeAutoscrollFrameIdRef = useRef(null) - const worktreeNativeAutoscrollLastFrameTimeRef = useRef(null) - const worktreeNativeLatestPointRef = useRef(null) - const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null) - const pendingRowRevealRetryRef = useRef<{ rowKey: string; count: number } | null>(null) - const pendingRevealFrameIdsRef = useRef>(new Set()) - const pendingRevealScrollRef = useRef(null) - const revealHighlightFrameIdRef = useRef(null) - const revealHighlightTimeoutRef = useRef(null) - const cancelPendingRevealFrames = useCallback(() => { - for (const frameId of pendingRevealFrameIdsRef.current) { - window.cancelAnimationFrame(frameId) - } - pendingRevealFrameIdsRef.current.clear() - }, []) - const schedulePendingRevealFrame = useCallback((callback: FrameRequestCallback) => { - const frameId = window.requestAnimationFrame((time) => { - pendingRevealFrameIdsRef.current.delete(frameId) - callback(time) - }) - pendingRevealFrameIdsRef.current.add(frameId) - }, []) - const clearRevealHighlightFrame = useCallback(() => { - if (revealHighlightFrameIdRef.current !== null) { - window.cancelAnimationFrame(revealHighlightFrameIdRef.current) - revealHighlightFrameIdRef.current = null - } - }, []) - const clearRevealHighlightTimeout = useCallback(() => { - if (revealHighlightTimeoutRef.current !== null) { - window.clearTimeout(revealHighlightTimeoutRef.current) - revealHighlightTimeoutRef.current = null - } - }, []) - const flashRevealedRow = useCallback( - (rowKey: string) => { - clearRevealHighlightTimeout() - clearRevealHighlightFrame() - // Why: clear before set restarts the CSS glow when revealing the same row repeatedly. - setHighlightedRevealRowKey(null) - revealHighlightFrameIdRef.current = window.requestAnimationFrame(() => { - revealHighlightFrameIdRef.current = null - setHighlightedRevealRowKey(rowKey) - revealHighlightTimeoutRef.current = window.setTimeout(() => { - revealHighlightTimeoutRef.current = null - setHighlightedRevealRowKey(null) - }, 1500) - }) - }, - [clearRevealHighlightFrame, clearRevealHighlightTimeout] - ) - const suppressWorktreeClickUntilRef = useRef(0) - const hasProjectGroups = projectGroups.length > 0 - const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual' - const canReorderProjectGroupHeaders = groupBy === 'repo' && hasProjectGroups - const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) - const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) - const lastVisibleRefreshKeyRef = useRef('') - const reportVisibleGitHubPRRefreshCandidates = useAppStore( - (s) => s.reportVisibleGitHubPRRefreshCandidates - ) - const cardProps = useAppStore((s) => s.worktreeCardProperties) - const rightSidebarShowsPR = useAppStore((s) => rightSidebarShowsPullRequestData(s)) - const keybindings = useAppStore((s) => s.keybindings) - const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) - const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration) - const settings = useAppStore((s) => s.settings) - const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true - const reorderRepos = useAppStore((s) => s.reorderRepos) - const folderBackedProjectGroupIds = useMemo( - () => - new Set( - projectGroups - .filter((group) => group.createdFrom === 'folder-scan') - .map((group) => group.id) - ), - [projectGroups] - ) - const projectGroupByIdForHeaderDrag = useMemo( - () => new Map(projectGroups.map((group) => [group.id, group])), - [projectGroups] - ) - - useEffect( - () => - installWorktreeVisibleRefreshVisibilityListener(() => { - if (document.visibilityState !== 'visible') { - // Why: row identity may be unchanged after a hidden window; reset the key so PR/CI rows refresh. - lastVisibleRefreshKeyRef.current = '__document_hidden__' - return - } - setDocumentVisibilityRevision((revision) => revision + 1) - }), - [] - ) - - // Why: reorder keeps scrollTop stable; flag direct scroll input so anchor-restore won't chase the moved row (jumpy drop). - const commitRepoReorder = useCallback( - (orderedIds: string[]) => { - const suppressUntil = - window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - suppressMeasurementAdjustmentUntilRef.current = suppressUntil - directScrollInputUntilRef.current = suppressUntil - reorderRepos(orderedIds) - }, - [reorderRepos] - ) - const orderedHostIds = useMemo( - () => - rows - .filter((row): row is HostHeaderRow => row.type === 'host-header') - .map((row) => row.hostId), - [rows] - ) - const hostDrag = useHostHeaderDrag({ - orderedHostIds, - onCommit: onReorderHostSections, - getScrollContainer: () => scrollRef.current - }) - useEffect(() => { - onHostDragActiveChange(hostDrag.state.draggingHostId !== null) - }, [hostDrag.state.draggingHostId, onHostDragActiveChange]) - useEffect(() => () => onHostDragActiveChange(false), [onHostDragActiveChange]) - const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows]) - const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows]) - const naturalDragWorktreeIds = useMemo( - () => - new Set( - rows.flatMap((row) => - row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] - ) - ), - [rows] - ) - const worktreeLineageDragRows = useMemo( - () => - rows - .filter((row): row is WorktreeItemRow => row.type === 'item') - .filter( - (row) => - row.sectionKey !== PINNED_GROUP_KEY || !naturalDragWorktreeIds.has(row.worktree.id) - ) - .map((row) => ({ worktreeId: row.worktree.id, depth: row.depth })), - [naturalDragWorktreeIds, rows] - ) - const getReorderDraggedIds = useCallback( - (draggedIds: readonly string[]) => - expandDraggedWorktreeIdsForVisibleLineage(worktreeLineageDragRows, draggedIds), - [worktreeLineageDragRows] - ) - const getReorderUnitDraggedIds = useCallback( - (sourceGroupKey: string, reorderDraggedIds: readonly string[]) => { - const group = worktreeDragUnitGroups.find((candidate) => candidate.key === sourceGroupKey) - if (!group) { - return reorderDraggedIds - } - const unitIds = new Set(group.worktreeIds) - const filtered = reorderDraggedIds.filter((worktreeId) => unitIds.has(worktreeId)) - return filtered.length > 0 ? filtered : reorderDraggedIds - }, - [worktreeDragUnitGroups] - ) - const { groupKeyByRowKey, groupIndexByRowKey } = useMemo( - () => getWorktreeDragIndexes(rows), - [rows] - ) - const refreshWorktreeDragSession = useCallback((): boolean => { - const session = worktreeDragSessionRef.current - const container = scrollRef.current - if (!session || !container) { - return false - } - - const refreshedSession = refreshWorktreeSidebarDragSession({ - session, - groups: worktreeDragGroups, - unitGroups: worktreeDragUnitGroups, - rects: getWorktreeSidebarDragRectsForGroup(container, session.sourceGroupKey) - }) - worktreeDragSessionRef.current = refreshedSession - return refreshedSession !== null - }, [worktreeDragGroups, worktreeDragUnitGroups]) - const computeWorktreeDropForGroup = useCallback( - (args: { - pointerY: number - groupKey: string - rects: readonly WorktreeSidebarDragRect[] - draggedIds: readonly string[] - draggingWorktreeId?: string | null - grab?: WorktreeSidebarDragGrab | null - anchor?: WorktreeSidebarDropAnchor | null - }): WorktreeSidebarDropPreview | null => { - const container = scrollRef.current - if (!container) { - return null - } - const group = worktreeDragUnitGroups.find((candidate) => candidate.key === args.groupKey) - if (!group) { - return null - } - const containerRect = container.getBoundingClientRect() - return computeWorktreeSidebarDropPreview({ - pointerY: args.pointerY, - containerTop: containerRect.top, - scrollTop: container.scrollTop, - rects: args.rects, - groupIds: group.worktreeIds, - draggedIds: args.draggedIds, - draggingWorktreeId: args.draggingWorktreeId, - fallbackGap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, - grab: args.grab, - anchor: args.anchor - }) - }, - [worktreeDragUnitGroups] - ) - const computeWorktreeDrop = useCallback( - (pointerY: number): WorktreeSidebarDropPreview | null => { - const session = worktreeDragSessionRef.current - const container = scrollRef.current - if (!session || !container) { - return null - } - const scrollTop = container.scrollTop - // Why: only real pointer or scroll movement should re-decide the slot; a - // card growing under a still pointer must not move it. - const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ - anchor: session.anchor, - pointerY, - scrollTop - }) - ? null - : session.anchor - const preview = computeWorktreeDropForGroup({ - pointerY, - groupKey: session.sourceGroupKey, - rects: session.rects, - draggedIds: session.reorderUnitDraggedIds, - draggingWorktreeId: session.draggingWorktreeId, - grab: session.grab, - anchor - }) - worktreeDragSessionRef.current = { - ...session, - anchor: preview ? { beforeWorktreeId: preview.dropAnchorId, pointerY, scrollTop } : null - } - return preview - }, - [computeWorktreeDropForGroup] - ) - const computeWorktreeStatusDrop = useCallback( - (args: { - pointerY: number - status: WorkspaceStatus - draggedIds: readonly string[] - }): WorktreeSidebarDropPreview | null => { - const container = scrollRef.current - if (!container) { - return null - } - const groupKey = getWorkspaceStatusGroupKey(args.status) - const session = worktreeDragSessionRef.current - const scrollTop = container.scrollTop - const heldAnchor = statusDropAnchorsRef.current.get(groupKey) ?? null - const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ - anchor: heldAnchor, - pointerY: args.pointerY, - scrollTop - }) - ? null - : heldAnchor - const preview = computeWorktreeDropForGroup({ - pointerY: args.pointerY, - groupKey, - rects: getWorktreeSidebarDragRectsForGroup(container, groupKey), - draggedIds: args.draggedIds, - draggingWorktreeId: session?.draggingWorktreeId ?? null, - grab: session?.grab ?? null, - anchor - }) - if (preview) { - statusDropAnchorsRef.current.set(groupKey, { - beforeWorktreeId: preview.dropAnchorId, - pointerY: args.pointerY, - scrollTop - }) - } else { - statusDropAnchorsRef.current.delete(groupKey) - } - return preview - }, - [computeWorktreeDropForGroup] - ) - const renderRows = useMemo(() => buildRenderableRows(rows), [rows]) - const sidebarRepoHeaderIdsByBucket = useMemo( - () => - getSidebarOrderedRepoHeaderIdsByBucket( - rows.filter((row): row is Row => row.type !== 'host-header') - ), - [rows] - ) - const sidebarProjectGroupHeaderIdsByBucket = useMemo( - () => - getSidebarOrderedProjectGroupHeaderIdsByBucket( - rows.filter((row): row is Row => row.type !== 'host-header'), - projectGroupByIdForHeaderDrag - ), - [projectGroupByIdForHeaderDrag, rows] - ) - const repoHeaderIndexByRepoId = useMemo(() => { - const map = new Map() - for (const repoIds of sidebarRepoHeaderIdsByBucket.values()) { - repoIds.forEach((repoId, index) => { - map.set(repoId, index) - }) - } - return map - }, [sidebarRepoHeaderIdsByBucket]) - const repoHeaderBucketByRepoId = useMemo(() => { - const map = new Map() - for (const [bucketKey, repoIds] of sidebarRepoHeaderIdsByBucket) { - for (const repoId of repoIds) { - map.set(repoId, bucketKey) - } - } - return map - }, [sidebarRepoHeaderIdsByBucket]) - const projectGroupHeaderIndexByGroupId = useMemo(() => { - const map = new Map() - for (const groupIds of sidebarProjectGroupHeaderIdsByBucket.values()) { - groupIds.forEach((groupId, index) => { - map.set(groupId, index) - }) - } - return map - }, [sidebarProjectGroupHeaderIdsByBucket]) - const projectGroupHeaderBucketByGroupId = useMemo(() => { - const map = new Map() - for (const [bucketKey, groupIds] of sidebarProjectGroupHeaderIdsByBucket) { - for (const groupId of groupIds) { - map.set(groupId, bucketKey) - } - } - return map - }, [sidebarProjectGroupHeaderIdsByBucket]) - const commitProjectGroupOrder = useCallback( - (repoId: string, projectGroupId: string | null, order: number) => { - void moveProjectToGroup(repoId, projectGroupId, order) - }, - [moveProjectToGroup] - ) - const commitProjectGroupHeaderOrder = useCallback( - (groupId: string, tabOrder: number) => { - if (!Number.isFinite(tabOrder)) { - return - } - const suppressUntil = - window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - suppressMeasurementAdjustmentUntilRef.current = suppressUntil - directScrollInputUntilRef.current = suppressUntil - void updateProjectGroup(groupId, { tabOrder }) - }, - [updateProjectGroup] - ) - // Drag applies only in manual order; still construct the controller inert for stable hook order. - const repoDrag = useRepoHeaderDrag({ - orderedRepoIds: allRepoIds, - sidebarRepoHeaderIdsByBucket, - repoById: repoMap, - usesProjectGroupOrdering: hasProjectGroups, - onCommitRepoOrder: commitRepoReorder, - onCommitProjectGroupOrder: commitProjectGroupOrder, - getScrollContainer: () => scrollRef.current - }) - const projectGroupDrag = useProjectGroupHeaderDrag({ - sidebarProjectGroupHeaderIdsByBucket, - projectGroupById: projectGroupByIdForHeaderDrag, - onCommitProjectGroupTabOrder: commitProjectGroupHeaderOrder, - getScrollContainer: () => scrollRef.current - }) - const [primaryActiveWorktreeRow, setPrimaryActiveWorktreeRow] = useState<{ - worktreeId: string - rowKey: string - } | null>(null) - useEffect(() => { - if (activeWorktreeId === null) { - setPrimaryActiveWorktreeRow(null) - return - } - setPrimaryActiveWorktreeRow((current) => { - if (current === null || current.worktreeId !== activeWorktreeId) { - return null - } - const rowStillVisible = rows.some( - (row) => - row.type === 'item' && - row.worktree.id === current.worktreeId && - row.rowKey === current.rowKey - ) - return rowStillVisible ? current : null - }) - }, [activeWorktreeId, rows]) - const getActiveSurfaceVariant = useCallback( - (row: WorktreeItemRow): ActiveSurfaceVariant => { - if (primaryActiveWorktreeRow?.worktreeId === row.worktree.id) { - return primaryActiveWorktreeRow.rowKey === row.rowKey ? 'primary' : 'secondary' - } - if ( - pinnedDisplayPolicy === 'duplicate-in-groups' && - activeWorktreeId === row.worktree.id && - isPinnedWorktreeRow(row) - ) { - return 'secondary' - } - return 'primary' - }, - [activeWorktreeId, pinnedDisplayPolicy, primaryActiveWorktreeRow] - ) - const handleImmediateWorktreeRowActivate = useCallback( - (worktreeId: string, rowKey: string | undefined): void => { - setPrimaryActiveWorktreeRow(rowKey ? { worktreeId, rowKey } : null) - onImmediateWorktreeActivate(worktreeId, rowKey) - }, - [onImmediateWorktreeActivate] - ) - const firstHeaderIndex = useMemo( - () => renderRows.findIndex((row) => row.type === 'header' || row.type === 'host-header'), - [renderRows] - ) - const repoHeaderSectionEndByRepoId = useMemo( - () => - getRepoHeaderSectionEndByRepoId({ - rows: renderRows, - firstHeaderIndex, - sidebarRepoHeaderIdsByBucket, - repoHeaderBucketByRepoId - }), - [firstHeaderIndex, renderRows, repoHeaderBucketByRepoId, sidebarRepoHeaderIdsByBucket] - ) - const projectGroupHeaderSectionEndByGroupId = useMemo( - () => - getProjectGroupHeaderSectionEndByGroupId({ - rows: renderRows, - firstHeaderIndex, - sidebarProjectGroupHeaderIdsByBucket, - projectGroupHeaderBucketByGroupId - }), - [ - firstHeaderIndex, - projectGroupHeaderBucketByGroupId, - renderRows, - sidebarProjectGroupHeaderIdsByBucket - ] - ) - const firstHeaderIndexRef = useRef(firstHeaderIndex) - firstHeaderIndexRef.current = firstHeaderIndex - const stickyHeaderIndexes = useMemo(() => getStickyHeaderIndexes(renderRows), [renderRows]) - const stickyHeaderIndexesRef = useRef(stickyHeaderIndexes) - stickyHeaderIndexesRef.current = stickyHeaderIndexes - const activeStickyHeaderIndexRef = useRef(null) - const activeStickyHostIndexRef = useRef(null) - const stickyRangeStartIndexRef = useRef(0) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const { - folderWorkspacePathStatuses, - fetchFolderWorkspacePathStatus, - getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus, - activeRuntimeEnvironmentId - } = useAppStore( - useShallow((s) => ({ - folderWorkspacePathStatuses: s.folderWorkspacePathStatuses, - fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus, - getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus, - activeRuntimeEnvironmentId: s.settings?.activeRuntimeEnvironmentId ?? null - })) - ) - const folderPathStatusRepoMembershipKey = useMemo( - () => - allRepoIds - .map((repoId) => { - const repo = repoMap.get(repoId) - return `${repoId}:${repo?.path ?? ''}:${repo?.projectGroupId ?? ''}:${repo?.connectionId ?? ''}` - }) - .join('\0'), - [allRepoIds, repoMap] - ) - const folderPathStatusSshConnectionKey = useMemo( - () => - [...sshConnectionStates.entries()] - .map(([connectionId, state]) => `${connectionId}:${state.status}`) - .sort() - .join('\0'), - [sshConnectionStates] - ) - const folderPathStatusCacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick( - folderWorkspacePathStatuses - ) - const projectGroupByIdForFolderPathStatus = useMemo( - () => new Map(projectGroups.map((group) => [group.id, group])), - [projectGroups] - ) - const folderWorkspaceByIdForFolderPathStatus = useMemo( - () => new Map(folderWorkspaces.map((workspace) => [workspace.id, workspace])), - [folderWorkspaces] - ) - const getFolderPathStatusRouteOptions = useCallback( - (request: Parameters[0]) => - getFolderPathStatusRouteOptionsForRows({ - request, - projectGroupsById: projectGroupByIdForFolderPathStatus, - folderWorkspacesById: folderWorkspaceByIdForFolderPathStatus - }), - [folderWorkspaceByIdForFolderPathStatus, projectGroupByIdForFolderPathStatus] - ) - useEffect(() => { - const requests = new Map< - string, - { - request: Parameters[0] - options?: { runtimeEnvironmentId: string | null } - } - >() - for (const group of projectGroups) { - if (group.parentPath) { - const request = { scope: 'project-group' as const, projectGroupId: group.id } - const options = getFolderPathStatusRouteOptions(request) - requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) - } - } - for (const workspace of folderWorkspaces) { - const request = { scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id } - const options = getFolderPathStatusRouteOptions(request) - requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) - } - for (const { request, options } of requests.values()) { - void fetchFolderWorkspacePathStatus(request, { force: true, ...options }) - } - }, [ - activeRuntimeEnvironmentId, - fetchFolderWorkspacePathStatus, - folderPathStatusRepoMembershipKey, - folderPathStatusSshConnectionKey, - folderWorkspaces, - getFolderPathStatusRouteOptions, - getFolderWorkspacePathStatusCacheKey, - projectGroups - ]) - const getCachedFolderWorkspacePathStatus = useCallback( - (request: Parameters[0]) => { - const options = getFolderPathStatusRouteOptions(request) - const cacheKey = getFolderWorkspacePathStatusCacheKey(request, options) - // Why: don't let an expired negative status keep folder workspaces disabled while a refresh is in flight. - void folderWorkspacePathStatuses[cacheKey] - void folderPathStatusCacheExpiryTick - return getFreshFolderWorkspacePathStatus(request, options) - }, - [ - folderWorkspacePathStatuses, - folderPathStatusCacheExpiryTick, - getFolderPathStatusRouteOptions, - getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus - ] - ) - const renderRowsRef = useRef(renderRows) - renderRowsRef.current = renderRows - const getVirtualItemKey = useCallback( - (index: number) => { - const row = renderRows[index] - if (!row) { - return `__stale_${index}` - } - return getRenderRowKey(row) - }, - [renderRows] - ) - const getExpectedVirtualRowKey = useCallback((element: Element) => { - const index = getVirtualRowIndex(element) - const row = index === null ? undefined : renderRowsRef.current[index] - return row ? getRenderRowKey(row) : null - }, []) - const isCurrentVirtualRowElement = useCallback( - (element: Element) => { - const expectedKey = getExpectedVirtualRowKey(element) - return ( - element.isConnected && - expectedKey !== null && - element.getAttribute('data-worktree-virtual-row-key') === expectedKey - ) - }, - [getExpectedVirtualRowKey] - ) - const measureCurrentVirtualRowElement = useCallback( - ( - element: HTMLDivElement, - entry: ResizeObserverEntry | undefined, - instance: Parameters>[2] - ) => { - if (!isCurrentVirtualRowElement(element)) { - const index = getVirtualRowIndex(element) - const measured = instance.getVirtualItems().find((item) => item.index === index) - // Why: a stale ResizeObserver row after remount would write a wrong height; return current size to no-op it. - return ( - measured?.size ?? - estimateRenderRowSize( - renderRowsRef.current, - index ?? -1, - firstHeaderIndexRef.current, - activeStickyHeaderIndexRef.current - ) - ) - } - const index = getVirtualRowIndex(element) - if ( - index !== null && - (renderRowsRef.current[index]?.type === 'header' || - renderRowsRef.current[index]?.type === 'host-header') - ) { - return estimateRenderRowSize( - renderRowsRef.current, - index, - firstHeaderIndexRef.current, - activeStickyHeaderIndexRef.current - ) - } - return measureVirtualElementSize(element, entry, instance) - }, - [isCurrentVirtualRowElement] - ) - const markScrollMovement = useCallback(() => { - suppressMeasurementAdjustmentUntilRef.current = - window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - }, []) - const markDirectScrollInput = useCallback(() => { - const suppressUntil = window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - suppressMeasurementAdjustmentUntilRef.current = suppressUntil - directScrollInputUntilRef.current = suppressUntil - }, []) - const { showScrollToTop, scrollToTop } = useWorktreeListScrollToTop({ - scrollElement, - onUserScrollIntent: markDirectScrollInput - }) - const hasDirectScrollInput = useCallback( - () => window.performance.now() < directScrollInputUntilRef.current, - [] - ) - const markRevealScroll = useCallback((targetTop: number) => { - pendingRevealScrollRef.current = createPendingRevealScroll(targetTop, window.performance.now()) - }, []) - const isRevealScrollSettlingNow = useCallback(() => { - const settling = isRevealScrollSettling({ - now: window.performance.now(), - pending: pendingRevealScrollRef.current, - scrollTop: scrollRef.current?.scrollTop ?? 0 - }) - if (!settling) { - pendingRevealScrollRef.current = null - } - return settling - }, []) - // Why: programmatic scrolls keep measurement correction quiet, but only direct input blocks anchor-restore retries. - // A reveal's smooth scroll is the exception: restoring the anchor mid-animation cancels it a few pixels in. - const shouldSkipScrollAnchorRestore = useCallback( - () => - window.performance.now() < directScrollInputUntilRef.current || isRevealScrollSettlingNow(), - [isRevealScrollSettlingNow] - ) - - const virtualizer = useVirtualizer({ - count: renderRows.length, - getScrollElement: () => scrollRef.current, - estimateSize: (index) => - estimateRenderRowSize( - renderRows, - index, - firstHeaderIndex, - activeStickyHeaderIndexRef.current - ), - measureElement: measureCurrentVirtualRowElement, - // Why: TanStack memoizes rangeExtractor by identity; header indexes must be deps or sticky slots go stale. - rangeExtractor: useCallback( - (range: Range) => { - stickyRangeStartIndexRef.current = range.startIndex - return extractWorktreeVirtualRowIndexes({ - range, - stickyHeaderIndexes, - rows: renderRowsRef.current - }) - }, - [stickyHeaderIndexes] - ), - overscan: 10, - gap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, - // Why: the sticky group header lives inside the virtual list, so scroll math needs the same top inset as the DOM reveal. - scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET, - isScrollingResetDelay: USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS, - // Why: sync-flushing rich card renders in the scroll listener stalls wheel input; async + overscan keeps rows filled. - useFlushSync: false, - // Why: seed scrollOffset from the ref (not 0) so the first getVirtualItems() after remount picks the right rows. - initialOffset: () => scrollOffsetRef.current, - getItemKey: getVirtualItemKey - }) - // Why: TanStack's default correction writes scrollTop while cards remeasure mid-wheel, which feels like rubber-banding. - // TODO(scroll-origin-migration): wall-clock suppression misclassifies under jank; migrate to programmaticScrollMarks + restoreSignal (see CombinedDiffViewer). - virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => - shouldAdjustWorktreeSidebarMeasuredRowScroll({ - isScrolling: instance.isScrolling, - now: window.performance.now(), - suppressUntil: suppressMeasurementAdjustmentUntilRef.current - }) - - useEffect(() => { - const handleSuppress = () => { - // Why: let an expanding agent row grow in place instead of TanStack compensating scrollTop. - suppressMeasurementAdjustmentUntilRef.current = - window.performance.now() + EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - } - window.addEventListener(SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT, handleSuppress) - return () => { - window.removeEventListener(SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT, handleSuppress) - } - }, []) - - React.useEffect(() => { - if (!pendingRevealWorktree) { - return - } - - if (agentSendTargetWorktreeId !== pendingRevealWorktree.worktreeId) { - const folderGroupKeys = getFolderWorkspaceRevealGroupKeys( - pendingRevealWorktree.worktreeId, - folderWorkspaces, - projectGroups - ) - if (folderGroupKeys.length > 0) { - for (const groupKey of folderGroupKeys) { - if (collapsedGroups.has(groupKey)) { - toggleGroup(groupKey) - } - } - } else { - const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId) - const targetRepo = targetWorktree ? repoMap.get(targetWorktree.repoId) : undefined - if (targetWorktree) { - const hostId = getWorktreeExecutionHostId(targetWorktree, targetRepo, defaultHostId) - const hostGroupKey = `host:${hostId}` - if (collapsedGroups.has(hostGroupKey)) { - toggleGroup(hostGroupKey) - } - - for (const parent of getWorktreeLineageAncestors( - targetWorktree, - worktreeLineageById, - worktreeMap - )) { - const lineageGroupKey = getLineageGroupKey(parent.id) - if (collapsedGroups.has(lineageGroupKey)) { - toggleGroup(lineageGroupKey) - } - } - - const groupKeys = - targetWorktree.isPinned && pinnedDisplayPolicy === 'single-location' - ? getPinnedWorktreeRevealCollapsedGroupKeys({ - worktree: targetWorktree, - collapsedGroups - }) - : getGroupKeysForWorktree( - groupBy, - targetWorktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups, - projectGrouping - ) - for (const groupKey of groupKeys) { - if (collapsedGroups.has(groupKey)) { - toggleGroup(groupKey) - } - } - } - } - } - - let cancelled = false - schedulePendingRevealFrame(() => { - if (cancelled) { - return - } - const targetWorktreeStillExists = sidebarWorkspaceStillExists( - pendingRevealWorktree.worktreeId, - worktrees, - folderWorkspaces - ) - const targetIndex = findPreferredRenderRowIndexForWorktree( - renderRows, - pendingRevealWorktree.worktreeId, - pinnedDisplayPolicy - ) - const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists }) - if (outcome === 'scroll-and-clear') { - const targetRow = renderRows[targetIndex] - const container = scrollRef.current - const retryExactRevealOnNextFrame = () => { - const previousRetry = pendingRevealRetryRef.current - const nextRetryCount = - previousRetry?.worktreeId === pendingRevealWorktree.worktreeId - ? previousRetry.count + 1 - : 1 - pendingRevealRetryRef.current = { - worktreeId: pendingRevealWorktree.worktreeId, - count: nextRetryCount - } - if (nextRetryCount <= 8) { - schedulePendingRevealFrame(() => { - if (!cancelled) { - setPendingRevealRetryTick((tick) => tick + 1) - } - }) - } else { - pendingRevealRetryRef.current = null - clearPendingRevealWorktreeId() - } - } - const revealedOption = container - ? revealMountedWorktreeElement( - container, - pendingRevealWorktree.worktreeId, - pendingRevealWorktree.behavior, - getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId), - markRevealScroll - ) - : null - if (revealedOption) { - if (pendingRevealWorktree.highlight) { - const revealedRowKey = - revealedOption.dataset.worktreeRowKey ?? getRenderRowSidebarKey(targetRow) - if (revealedRowKey) { - flashRevealedRow(revealedRowKey) - } - } - if (pendingRevealWorktree.beginRename) { - setRenamingWorktreeId({ - worktreeId: pendingRevealWorktree.worktreeId, - rowKey: revealedOption.dataset.worktreeRowKey - }) - } - pendingRevealRetryRef.current = null - clearPendingRevealWorktreeId() - return - } - - if (targetRow?.type !== 'lineage-group') { - // Why: virtual indexing can leave the card edge clipped; stage it into the window, then retry the exact DOM reveal. - virtualizer.scrollToIndex(targetIndex, { - align: 'auto', - behavior: 'auto' - }) - retryExactRevealOnNextFrame() - return - } - - // Why: for lineage groups the virtual row is only a staging target; jump into the window, then retry the exact reveal. - virtualizer.scrollToIndex(targetIndex, { - align: 'auto', - behavior: 'auto' - }) - retryExactRevealOnNextFrame() - return - } - if (outcome === 'clear') { - pendingRevealRetryRef.current = null - clearPendingRevealWorktreeId() - } - }) - return () => { - cancelled = true - cancelPendingRevealFrames() - } - }, [ - pendingRevealWorktree, - agentSendTargetWorktreeId, - groupBy, - worktrees, - folderWorkspaces, - repoMap, - prCache, - worktreeLineageById, - worktreeMap, - renderRows, - virtualizer, - clearPendingRevealWorktreeId, - toggleGroup, - collapsedGroups, - defaultHostId, - workspaceStatuses, - settings, - pinnedDisplayPolicy, - projectGrouping, - projectGroups, - pendingRevealRetryTick, - flashRevealedRow, - markRevealScroll, - setRenamingWorktreeId, - schedulePendingRevealFrame, - cancelPendingRevealFrames - ]) - - React.useEffect(() => { - if (!pendingRevealSidebarRow) { - return - } - - const isProjectHeaderTarget = - pendingRevealSidebarRow.rowKey.startsWith('project-group:') || - pendingRevealSidebarRow.rowKey.startsWith('project:') || - pendingRevealSidebarRow.rowKey.startsWith('repo:') - if (isProjectHeaderTarget && groupBy !== 'repo') { - return - } - - let toggledAncestor = false - for (const groupKey of getSidebarRowRevealAncestorKeys({ - rowKey: pendingRevealSidebarRow.rowKey, - repoMap, - projectGroups, - projectGrouping - })) { - if (collapsedGroups.has(groupKey)) { - toggleGroup(groupKey) - toggledAncestor = true - } - } - if (toggledAncestor) { - return - } - - let cancelled = false - const retryPendingReveal = () => { - const previousRetry = pendingRowRevealRetryRef.current - const nextRetryCount = - previousRetry?.rowKey === pendingRevealSidebarRow.rowKey ? previousRetry.count + 1 : 1 - pendingRowRevealRetryRef.current = { - rowKey: pendingRevealSidebarRow.rowKey, - count: nextRetryCount - } - if (nextRetryCount <= 8) { - schedulePendingRevealFrame(() => { - if (!cancelled) { - setPendingRevealRetryTick((tick) => tick + 1) - } - }) - return true - } - return false - } - schedulePendingRevealFrame(() => { - if (cancelled) { - return - } - const targetIndex = renderRows.findIndex((row) => - rowKeyMatchesRenderRow(row, pendingRevealSidebarRow.rowKey) - ) - if (targetIndex === -1) { - if (retryPendingReveal()) { - return - } - pendingRowRevealRetryRef.current = null - clearPendingRevealSidebarRow() - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.sidebarRowMissing', - 'Target no longer exists' - ) - ) - return - } - - const retryExactRevealOnNextFrame = () => { - if (retryPendingReveal()) { - return - } - pendingRowRevealRetryRef.current = null - clearPendingRevealSidebarRow() - } - - const container = scrollRef.current - const revealedElement = container - ? revealMountedSidebarRowElement( - container, - pendingRevealSidebarRow.rowKey, - pendingRevealSidebarRow.behavior, - markRevealScroll - ) - : null - if (revealedElement) { - if (pendingRevealSidebarRow.highlight) { - flashRevealedRow(pendingRevealSidebarRow.rowKey) - } - pendingRowRevealRetryRef.current = null - clearPendingRevealSidebarRow() - return - } - - virtualizer.scrollToIndex(targetIndex, { - align: 'auto', - behavior: 'auto' - }) - retryExactRevealOnNextFrame() - }) - - return () => { - cancelled = true - cancelPendingRevealFrames() - } - }, [ - pendingRevealSidebarRow, - repoMap, - projectGroups, - projectGrouping, - collapsedGroups, - groupBy, - toggleGroup, - renderRows, - virtualizer, - pendingRevealRetryTick, - flashRevealedRow, - markRevealScroll, - clearPendingRevealSidebarRow, - schedulePendingRevealFrame, - cancelPendingRevealFrames - ]) - - const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache)) - const issueCacheLen = useAppStore((s) => countRecordKeysByReference(s.issueCache)) - const renderRowKeySignature = useMemo( - () => renderRows.map(getRenderRowKey).join('\n'), - [renderRows] - ) - const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows]) - const lineageRowRekeys = useMemo(() => buildLineageRowRekeyMap(renderRows), [renderRows]) - const totalSize = virtualizer.getTotalSize() - const virtualItems = virtualizer.getVirtualItems() - const activeStickyIndexes = getActiveStickyIndexesForScroll({ - rows: renderRows, - rangeStartIndex: stickyRangeStartIndexRef.current, - scrollOffset: virtualizer.scrollOffset ?? scrollOffsetRef.current, - stickyHeaderIndexes, - virtualItems - }) - activeStickyHeaderIndexRef.current = activeStickyIndexes.groupIndex - activeStickyHostIndexRef.current = activeStickyIndexes.hostIndex - - const measureMountedRows = useCallback(() => { - virtualizer.elementsCache.forEach((element) => { - if (!isCurrentVirtualRowElement(element)) { - return - } - virtualizer.measureElement(element) - }) - }, [isCurrentVirtualRowElement, virtualizer]) - const measureVirtualRowElement = useCallback( - (element: HTMLDivElement | null) => { - if (!element) { - virtualizer.measureElement(null) - return - } - if (!isCurrentVirtualRowElement(element)) { - return - } - virtualizer.measureElement(element) - }, - [isCurrentVirtualRowElement, virtualizer] - ) - - useLayoutEffect(() => { - pruneStaleVirtualRowElementCache({ - activeRowKeys: activeRenderRowKeys, - virtualizer - }) - // Why: a stale retained element after delete/collapse measures 0px and corrupts the next slot; measure only key-matched rows. - measureMountedRows() - const frameId = window.requestAnimationFrame(measureMountedRows) - return () => window.cancelAnimationFrame(frameId) - }, [ - activeRenderRowKeys, - prCacheLen, - issueCacheLen, - measureMountedRows, - renderRowKeySignature, - virtualizer - ]) - - useVirtualizedScrollAnchor({ - anchorRef: scrollAnchorRef, - getItemElementKey: getVirtualRowKey, - getRowKey: getRenderRowKey, - itemElementSelector: '[data-worktree-virtual-row]', - rekeyedRowKeys: lineageRowRekeys, - rows: renderRows, - scrollElementRef: scrollRef, - scrollOffsetRef, - hasDirectScrollInput, - shouldSkipRestore: shouldSkipScrollAnchorRestore, - totalSize, - virtualizer - }) - - const recordCurrentScrollAnchor = useCallback(() => { - scrollRef.current?.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)) - }, []) - const toggleGroupWithScrollAnchor = useCallback( - (groupKey: string) => { - recordCurrentScrollAnchor() - toggleGroup(groupKey) - }, - [recordCurrentScrollAnchor, toggleGroup] - ) - // Why: memo'd WorktreeCard needs a per-group-key stable onLineageToggle - // identity to bail out of re-renders; see worktree-lineage-toggle-handler-cache. - const getLineageToggleHandler = useMemo( - () => createLineageToggleHandlerCache(toggleGroupWithScrollAnchor), - [toggleGroupWithScrollAnchor] - ) - - const navigateWorktree = useCallback( - (direction: 'up' | 'down') => { - // Why: cycle over the rows the sidebar actually rendered — collapsing a group - // means "not now", and a rebuilt near-copy would drift from what is on screen - // (host sections, pinned placement, folder workspaces). - const nextWorktreeId = resolveCycledWorktreeId({ - worktreeIds: getCyclableWorktreeIds(rows, pinnedDisplayPolicy), - activeWorktreeId, - direction - }) - if (nextWorktreeId === null) { - return - } - - // Why: keyboard cycling is real navigation; route through the activation helper that records history. - activateAndRevealWorktree(nextWorktreeId) - - const rowIndex = findPreferredRenderRowIndexForWorktree( - renderRows, - nextWorktreeId, - pinnedDisplayPolicy - ) - if (rowIndex !== -1) { - virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) - } - }, - [rows, renderRows, activeWorktreeId, virtualizer, pinnedDisplayPolicy] - ) - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (activeModal !== 'none' || isEditableTarget(e.target)) { - return - } - - const platform = getShortcutPlatform() - if (keybindingMatchesAction('sidebar.focusWorktreeList', e, platform, keybindings)) { - scrollRef.current?.focus() - e.preventDefault() - return - } - - const direction = keybindingMatchesAction('worktree.navigateUp', e, platform, keybindings) - ? 'up' - : keybindingMatchesAction('worktree.navigateDown', e, platform, keybindings) - ? 'down' - : null - if (direction) { - markDirectScrollInput() - navigateWorktree(direction) - e.preventDefault() - } - } - - window.addEventListener('keydown', handleKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) - }, [activeModal, keybindings, markDirectScrollInput, navigateWorktree]) - - const handleContainerKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - if (e.target !== e.currentTarget) { - return - } - markDirectScrollInput() - navigateWorktree(e.key === 'ArrowUp' ? 'up' : 'down') - e.preventDefault() - } else if (e.key === 'Enter') { - const helper = document.querySelector( - '.xterm-helper-textarea' - ) as HTMLTextAreaElement | null - if (helper) { - helper.focus() - } - e.preventDefault() - } else if (['PageUp', 'PageDown', 'Home', 'End', ' '].includes(e.key)) { - markDirectScrollInput() - } - }, - [markDirectScrollInput, navigateWorktree] - ) - - const handleScrollPointerDown = useCallback( - (event: React.PointerEvent) => { - const scrollbarWidth = event.currentTarget.offsetWidth - event.currentTarget.clientWidth - if (scrollbarWidth <= 0) { - return - } - const rect = event.currentTarget.getBoundingClientRect() - if (event.clientX >= rect.right - scrollbarWidth) { - markDirectScrollInput() - } - }, - [markDirectScrollInput] - ) - const handleScroll = useCallback(() => { - markScrollMovement() - }, [markScrollMovement]) - - const cancelWorktreePointerAutoscroll = useCallback(() => { - if (worktreePointerAutoscrollFrameIdRef.current !== null) { - window.cancelAnimationFrame(worktreePointerAutoscrollFrameIdRef.current) - worktreePointerAutoscrollFrameIdRef.current = null - } - worktreePointerAutoscrollLastFrameTimeRef.current = null - }, []) - - const cancelWorktreeNativeAutoscroll = useCallback(() => { - if (worktreeNativeAutoscrollFrameIdRef.current !== null) { - window.cancelAnimationFrame(worktreeNativeAutoscrollFrameIdRef.current) - worktreeNativeAutoscrollFrameIdRef.current = null - } - worktreeNativeAutoscrollLastFrameTimeRef.current = null - worktreeNativeLatestPointRef.current = null - }, []) - - const cleanupWorktreePointerDrag = useCallback(() => { - const drag = worktreePointerDragRef.current - cancelWorktreePointerAutoscroll() - setNativeLineageDropTargetId(null) - if (!drag) { - return - } - if (drag.frameId !== null) { - window.cancelAnimationFrame(drag.frameId) - } - drag.preview?.remove() - worktreePointerDragRef.current = null - setSidebarPointerDragDocumentStyles(false) - setDragOverStatus(null) - setPinDragOver(false) - clearWorkspaceKanbanSidebarDropTargetVisual() - onWorkspaceBoardDragPreviewCancel() - }, [cancelWorktreePointerAutoscroll, onWorkspaceBoardDragPreviewCancel]) - - const clearWorktreeDrag = useCallback(() => { - cleanupWorktreePointerDrag() - cancelWorktreeNativeAutoscroll() - worktreeDragSessionRef.current = null - statusDropAnchorsRef.current.clear() - setWorktreeDragState(WORKTREE_ROW_DRAG_INITIAL_STATE) - }, [cancelWorktreeNativeAutoscroll, cleanupWorktreePointerDrag]) - - const setScrollRootRef = useCallback( - (node: HTMLDivElement | null) => { - if (node === null && scrollRef.current !== null) { - // Why: drag previews, autoscroll frames, and reveal snapshots are tied to the scroll root; clear them before it unmounts. - cancelPendingRevealFrames() - clearRevealHighlightFrame() - clearRevealHighlightTimeout() - clearWorktreeDrag() - } - scrollRef.current = node - setScrollElement(node) - }, - [ - cancelPendingRevealFrames, - clearRevealHighlightFrame, - clearRevealHighlightTimeout, - clearWorktreeDrag - ] - ) - - const getEligibleLineageDropTarget = useCallback( - ( - target: WorktreeSidebarStatusDropTarget & { lineageParentId: string | null }, - draggedIds: readonly string[] - ): WorktreeSidebarStatusDropTarget & { lineageParentId: string | null } => { - const parentId = target.lineageParentId - if (!parentId) { - return target - } - const canAssignAll = draggedIds.every((draggedId) => { - const child = worktreeMap.get(draggedId) - if (!child) { - return false - } - const candidateParent = worktreeMap.get(parentId) - return Boolean( - candidateParent && - isEligibleWorktreeParent({ - child, - candidateParent, - lineageById: worktreeLineageById, - worktreeMap, - repoMap, - cyclicLineageIds - }) - ) - }) - return canAssignAll ? target : { ...target, lineageParentId: null } - }, - [cyclicLineageIds, repoMap, worktreeLineageById, worktreeMap] - ) - - const commitWorktreeLineageParentDrop = useCallback( - (draggedIds: readonly string[], parentId: string): boolean => { - const target = getEligibleLineageDropTarget( - { status: null, isPinDrop: false, lineageParentId: parentId }, - draggedIds - ) - if (!target.lineageParentId) { - return false - } - void Promise.all( - draggedIds.map((id) => assignWorktreeParent(id, { parentWorktreeId: parentId })) - ).catch((err) => { - console.error('Failed to nest workspace:', err) - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.failedNestWorkspace', - 'Failed to nest workspace' - ) - ) - }) - return true - }, - [assignWorktreeParent, getEligibleLineageDropTarget] - ) - - const clearReorderedWorktreeParents = useCallback( - (args: { draggedIds: readonly string[]; sourceGroupKey: string }) => { - const sourceGroup = worktreeDragGroups.find((group) => group.key === args.sourceGroupKey) - if (!sourceGroup) { - return - } - const ids = getReorderedWorktreeIdsToUnnest({ - draggedIds: args.draggedIds, - sourceGroupIds: sourceGroup.worktreeIds, - lineageById: worktreeLineageById, - worktreeMap, - cyclicLineageIds - }) - if (ids.length === 0) { - return - } - // Why: dropping a nested card on a reorder line is the un-nest escape hatch; clear only the dragged children. - void Promise.all(ids.map((id) => updateWorktreeLineage(id, { noParent: true }))).catch( - (err) => { - console.error('Failed to unnest workspace:', err) - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.failedUnnestWorkspace', - 'Failed to unnest workspace' - ) - ) - } - ) - }, - [cyclicLineageIds, updateWorktreeLineage, worktreeDragGroups, worktreeLineageById, worktreeMap] - ) - - const flushWorktreePointerDrag = useCallback(() => { - const drag = worktreePointerDragRef.current - if (!drag) { - return - } - drag.frameId = null - if (!drag.active || !drag.preview) { - return - } - updateSidebarDragPreviewPosition({ - preview: drag.preview, - pointerX: drag.currentX, - pointerY: drag.currentY, - offsetX: drag.previewOffsetX, - offsetY: drag.previewOffsetY - }) - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - // Why: show the board preview as soon as a card drag begins so the drop target is visible up front, not only at the sidebar edge. - if ( - !drag.workspaceBoardDragPreviewRequested && - !workspaceBoardOpen && - !hasWorkspaceKanbanSidebarDropBoard() - ) { - drag.workspaceBoardDragPreviewRequested = true - onWorkspaceBoardDragPreviewStart() - } - const boardTarget = updateWorkspaceKanbanSidebarDropTargetVisual({ - x: drag.currentX, - y: drag.currentY, - shouldShowDropIndicator: (target) => - Boolean( - target.status && - shouldShowWorkspaceBoardDropIndicator(drag.reorderDraggedIds, target.status) - ) - }) - drag.latestBoardDropTarget = { - target: boardTarget, - x: drag.currentX, - y: drag.currentY - } - if (isWorkspaceKanbanSidebarDropPointInBoard(drag.currentX, drag.currentY)) { - onWorkspaceBoardDragPreviewCommit() - } - if (boardTarget.status || boardTarget.isPinDrop) { - drag.latestStatusDropTarget = null - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.pointerY === drag.currentY && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - } - ) - return - } - - const sidebarContainer = scrollRef.current - const preferredStatusTarget = getEligibleLineageDropTarget( - sidebarContainer - ? getPointerDropStatusTarget({ - container: sidebarContainer, - x: drag.currentX, - y: drag.currentY - }) - : { status: null, isPinDrop: false, lineageParentId: null }, - drag.draggedIds - ) - if (preferredStatusTarget.lineageParentId) { - updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, null) - clearWorkspaceKanbanSidebarDropTargetVisual() - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.pointerY === drag.currentY && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - } - ) - return - } - if ( - shouldPreferSidebarStatusDropTarget({ - sourceGroupKey: drag.sourceGroupKey, - target: preferredStatusTarget, - workspaceStatuses - }) - ) { - const statusDrop = preferredStatusTarget.status - ? computeWorktreeStatusDrop({ - pointerY: drag.currentY, - status: preferredStatusTarget.status, - draggedIds: drag.reorderDraggedIds - }) - : null - if (statusDrop) { - updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, statusDrop) - clearWorkspaceKanbanSidebarDropTargetVisual() - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === statusDrop.dropIndex && - prev.dropIndicatorY === statusDrop.dropIndicatorY && - prev.pointerY === drag.currentY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - statusDrop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...statusDrop, pointerY: drag.currentY } - ) - return - } - updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, statusDrop) - setDragOverStatus(preferredStatusTarget.status) - setPinDragOver(preferredStatusTarget.isPinDrop) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.pointerY === drag.currentY && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - } - ) - return - } - - const drop = computeWorktreeDrop(drag.currentY) - if (!drop) { - const target = preferredStatusTarget - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: drag.currentY, - status: target.status, - draggedIds: drag.reorderDraggedIds - }) - : null - if (statusDrop) { - updateLatestWorktreeStatusDropTarget(drag, target, statusDrop) - clearWorkspaceKanbanSidebarDropTargetVisual() - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === statusDrop.dropIndex && - prev.dropIndicatorY === statusDrop.dropIndicatorY && - prev.pointerY === drag.currentY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - statusDrop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...statusDrop, pointerY: drag.currentY } - ) - return - } - updateLatestWorktreeStatusDropTarget(drag, target, statusDrop) - setDragOverStatus(target.status) - setPinDragOver(target.isPinDrop) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.pointerY === drag.currentY && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - } - ) - return - } - drag.latestStatusDropTarget = null - clearWorkspaceKanbanSidebarDropTargetVisual() - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === drop.dropIndex && - prev.dropIndicatorY === drop.dropIndicatorY && - prev.pointerY === drag.currentY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - drop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...drop, pointerY: drag.currentY } - ) - }, [ - clearWorktreeDrag, - computeWorktreeDrop, - computeWorktreeStatusDrop, - onWorkspaceBoardDragPreviewStart, - refreshWorktreeDragSession, - onWorkspaceBoardDragPreviewCommit, - shouldShowWorkspaceBoardDropIndicator, - getEligibleLineageDropTarget, - workspaceBoardOpen, - workspaceStatuses - ]) - - const scheduleWorktreePointerDragFrame = useCallback( - (drag: WorktreePointerDrag) => { - if (drag.frameId !== null) { - return - } - drag.frameId = window.requestAnimationFrame(flushWorktreePointerDrag) - }, - [flushWorktreePointerDrag] - ) - - const runWorktreePointerAutoscrollFrame = useCallback( - (frameTime: number) => { - worktreePointerAutoscrollFrameIdRef.current = null - const drag = worktreePointerDragRef.current - const container = scrollRef.current - const session = worktreeDragSessionRef.current - if (!drag?.active || !container || !session) { - cancelWorktreePointerAutoscroll() - return - } - - const previousFrameTime = worktreePointerAutoscrollLastFrameTimeRef.current ?? frameTime - worktreePointerAutoscrollLastFrameTimeRef.current = frameTime - const autoscroll = getWorktreeSidebarDragAutoscroll({ - point: { clientX: drag.currentX, clientY: drag.currentY }, - containerRect: container.getBoundingClientRect(), - scrollTop: container.scrollTop, - scrollHeight: container.scrollHeight, - clientHeight: container.clientHeight, - elapsedMs: frameTime - previousFrameTime - }) - if (autoscroll) { - markScrollMovement() - container.scrollTop = autoscroll.scrollTop - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - scheduleWorktreePointerDragFrame(drag) - } - - worktreePointerAutoscrollFrameIdRef.current = window.requestAnimationFrame( - runWorktreePointerAutoscrollFrame - ) - }, - [ - cancelWorktreePointerAutoscroll, - clearWorktreeDrag, - markScrollMovement, - refreshWorktreeDragSession, - scheduleWorktreePointerDragFrame - ] - ) - - const startWorktreePointerAutoscroll = useCallback(() => { - if (worktreePointerAutoscrollFrameIdRef.current !== null) { - return - } - worktreePointerAutoscrollLastFrameTimeRef.current = null - worktreePointerAutoscrollFrameIdRef.current = window.requestAnimationFrame( - runWorktreePointerAutoscrollFrame - ) - }, [runWorktreePointerAutoscrollFrame]) - - const beginWorktreePointerDrag = useCallback( - (drag: WorktreePointerDrag) => { - const { preview, offsetX, offsetY, height } = createSidebarDragPreview({ - sourceRow: drag.sourceRow, - pointerX: drag.currentX, - pointerY: drag.currentY, - draggedCount: drag.draggedIds.length - }) - drag.active = true - drag.preview = preview - drag.previewOffsetX = offsetX - drag.previewOffsetY = offsetY - suppressWorktreeClickUntilRef.current = window.performance.now() + 500 - setSidebarPointerDragDocumentStyles(true) - worktreeDragSessionRef.current = { - draggingWorktreeId: drag.worktreeId, - sourceGroupKey: drag.sourceGroupKey, - draggedIds: drag.draggedIds, - reorderDraggedIds: drag.reorderDraggedIds, - reorderUnitDraggedIds: drag.reorderUnitDraggedIds, - rects: drag.rects, - // Why: reuse the floating preview's own offset so the hit test tracks the - // card the user sees, not the raw pointer. - grab: getWorktreeSidebarDragGrab({ offsetY, height }), - anchor: null - } - setWorktreeDragState({ - draggingWorktreeId: drag.worktreeId, - sourceGroupKey: drag.sourceGroupKey, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - }) - startWorktreePointerAutoscroll() - scheduleWorktreePointerDragFrame(drag) - }, - [scheduleWorktreePointerDragFrame, startWorktreePointerAutoscroll] - ) - - const handleWorktreeRowPointerDown = useCallback( - (event: React.PointerEvent, worktreeId: string, rowKey: string) => { - if (event.button !== 0 || event.pointerType === 'touch') { - return - } - const sourceRow = event.currentTarget - if (isSidebarPointerDragBlocked(event.target, sourceRow)) { - return - } - const sourceGroupKey = groupKeyByRowKey.get(rowKey) - const container = scrollRef.current - if (!sourceGroupKey || !container) { - return - } - const rects = getWorktreeSidebarDragRectsForGroup(container, sourceGroupKey) - const canPreviewWorkspaceBoardOnDrag = - !workspaceBoardOpen && - onWorkspaceBoardDragPreviewStart !== NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK - if ( - rects.length <= 1 && - !hasWorkspaceKanbanSidebarDropBoard() && - !canPreviewWorkspaceBoardOnDrag - ) { - return - } - const draggedIds = - selectedWorktreeIds.has(worktreeId) && selectedWorktrees.length > 1 - ? selectedWorktrees.map((worktree) => worktree.id) - : [worktreeId] - const reorderDraggedIds = getReorderDraggedIds(draggedIds) - const reorderUnitDraggedIds = getReorderUnitDraggedIds(sourceGroupKey, reorderDraggedIds) - worktreePointerDragRef.current = { - pointerId: event.pointerId, - sourceRow, - startX: event.clientX, - startY: event.clientY, - currentX: event.clientX, - currentY: event.clientY, - worktreeId, - draggedIds, - reorderDraggedIds, - reorderUnitDraggedIds, - sourceGroupKey, - rects, - active: false, - preview: null, - previewOffsetX: 0, - previewOffsetY: 0, - workspaceBoardDragPreviewRequested: false, - frameId: null, - latestBoardDropTarget: null, - latestStatusDropTarget: null - } - }, - [ - getReorderDraggedIds, - getReorderUnitDraggedIds, - groupKeyByRowKey, - onWorkspaceBoardDragPreviewStart, - selectedWorktreeIds, - selectedWorktrees, - workspaceBoardOpen - ] - ) - - const handleWorktreeRowClickCapture = useCallback((event: React.MouseEvent) => { - if (window.performance.now() >= suppressWorktreeClickUntilRef.current) { - return - } - event.preventDefault() - event.stopPropagation() - }, []) - - useEffect(() => { - const handlePointerMove = (event: PointerEvent): void => { - const drag = worktreePointerDragRef.current - if (!drag || event.pointerId !== drag.pointerId) { - return - } - drag.currentX = event.clientX - drag.currentY = event.clientY - if (!drag.active) { - const distance = Math.hypot(drag.currentX - drag.startX, drag.currentY - drag.startY) - if (distance < SIDEBAR_POINTER_DRAG_THRESHOLD_PX) { - return - } - beginWorktreePointerDrag(drag) - } - event.preventDefault() - event.stopPropagation() - scheduleWorktreePointerDragFrame(drag) - } - - const handlePointerUp = (event: PointerEvent): void => { - const drag = worktreePointerDragRef.current - if (!drag || event.pointerId !== drag.pointerId) { - return - } - drag.currentX = event.clientX - drag.currentY = event.clientY - if (!drag.active) { - worktreePointerDragRef.current = null - return - } - event.preventDefault() - event.stopPropagation() - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const boardDropTarget = resolveWorkspaceKanbanCardDropCommitTarget({ - currentTarget: getWorkspaceKanbanSidebarDropTarget(event.clientX, event.clientY), - latestTrackedTarget: drag.latestBoardDropTarget, - x: event.clientX, - y: event.clientY - }) - if (isWorkspaceKanbanSidebarDropPointInBoard(event.clientX, event.clientY)) { - onWorkspaceBoardDragPreviewCommit() - } - if (boardDropTarget.isPinDrop) { - onPinWorktrees(drag.draggedIds) - } else if (boardDropTarget.status) { - onDropWorktreesOnWorkspaceBoard({ - worktreeIds: drag.reorderDraggedIds, - status: boardDropTarget.status, - // Why: the target counts rendered cards, but the groups are the full - // lane. Board search can make those two differ. - dropIndex: resolveWorkspaceKanbanSidebarFullLaneDropIndex( - boardDropTarget.status, - boardDropTarget.dropIndex - ), - groups: getWorkspaceKanbanSidebarDropGroups() - }) - } else { - const preferredStatusTarget = getEligibleLineageDropTarget( - scrollRef.current - ? getPointerDropStatusTarget({ - container: scrollRef.current, - x: event.clientX, - y: event.clientY - }) - : { status: null, isPinDrop: false, lineageParentId: null }, - drag.draggedIds - ) - if (preferredStatusTarget.lineageParentId) { - commitWorktreeLineageParentDrop(drag.draggedIds, preferredStatusTarget.lineageParentId) - clearWorktreeDrag() - return - } - if ( - shouldPreferSidebarStatusDropTarget({ - sourceGroupKey: drag.sourceGroupKey, - target: preferredStatusTarget, - workspaceStatuses - }) - ) { - const statusDrop = preferredStatusTarget.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: preferredStatusTarget.status, - draggedIds: drag.reorderDraggedIds - }) - : null - if (preferredStatusTarget.isPinDrop) { - onPinWorktrees(drag.draggedIds) - } else if (preferredStatusTarget.status) { - if (statusDrop) { - onMoveWorktreesToStatusAtIndex({ - worktreeIds: drag.reorderDraggedIds, - status: preferredStatusTarget.status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - } else { - onMoveWorktreesToStatus(drag.reorderDraggedIds, preferredStatusTarget.status) - } - } - clearWorktreeDrag() - return - } - const drop = computeWorktreeDrop(event.clientY) - if (drop) { - onReorderWorktrees({ - groups: worktreeDragGroups, - sourceGroupKey: drag.sourceGroupKey, - draggedIds: drag.reorderDraggedIds, - dropIndex: getFullDropIndexForWorktreeDragUnit({ - groups: worktreeDragUnitGroups, - sourceGroupKey: drag.sourceGroupKey, - dropIndex: drop.dropIndex - }) - }) - clearReorderedWorktreeParents({ - draggedIds: drag.draggedIds, - sourceGroupKey: drag.sourceGroupKey - }) - } else if (scrollRef.current) { - const currentTarget = preferredStatusTarget - const currentPreview = currentTarget.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: currentTarget.status, - draggedIds: drag.reorderDraggedIds - }) - : null - const { target, preview: statusDrop } = resolveWorktreeSidebarStatusDropCommitTarget({ - currentTarget, - currentPreview, - latestTrackedTarget: drag.latestStatusDropTarget, - x: event.clientX, - y: event.clientY - }) - if (target.lineageParentId) { - commitWorktreeLineageParentDrop(drag.draggedIds, target.lineageParentId) - } else if (target.isPinDrop) { - onPinWorktrees(drag.draggedIds) - } else if (target.status) { - if (statusDrop) { - onMoveWorktreesToStatusAtIndex({ - worktreeIds: drag.reorderDraggedIds, - status: target.status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - } else { - onMoveWorktreesToStatus(drag.reorderDraggedIds, target.status) - } - } - } - } - clearWorktreeDrag() - } - - const handlePointerCancel = (event: PointerEvent): void => { - const drag = worktreePointerDragRef.current - if (!drag || event.pointerId !== drag.pointerId) { - return - } - clearWorktreeDrag() - } - - window.addEventListener('pointermove', handlePointerMove, { capture: true }) - window.addEventListener('pointerup', handlePointerUp, { capture: true }) - window.addEventListener('pointercancel', handlePointerCancel, { capture: true }) - return () => { - window.removeEventListener('pointermove', handlePointerMove, { capture: true }) - window.removeEventListener('pointerup', handlePointerUp, { capture: true }) - window.removeEventListener('pointercancel', handlePointerCancel, { capture: true }) - } - }, [ - beginWorktreePointerDrag, - clearWorktreeDrag, - clearReorderedWorktreeParents, - commitWorktreeLineageParentDrop, - computeWorktreeDrop, - computeWorktreeStatusDrop, - getEligibleLineageDropTarget, - onMoveWorktreesToStatus, - onMoveWorktreesToStatusAtIndex, - onDropWorktreesOnWorkspaceBoard, - onPinWorktrees, - onReorderWorktrees, - onWorkspaceBoardDragPreviewCommit, - refreshWorktreeDragSession, - scheduleWorktreePointerDragFrame, - shouldShowWorkspaceBoardDropIndicator, - worktreeDragGroups, - worktreeDragUnitGroups, - workspaceStatuses - ]) - - useEffect(() => { - const handleClick = (event: MouseEvent): void => { - if (window.performance.now() >= suppressWorktreeClickUntilRef.current) { - return - } - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() - } - - document.addEventListener('click', handleClick, true) - return () => document.removeEventListener('click', handleClick, true) - }, []) - - const runWorktreeNativeAutoscrollFrame = useCallback( - (frameTime: number) => { - worktreeNativeAutoscrollFrameIdRef.current = null - const point = worktreeNativeLatestPointRef.current - const container = scrollRef.current - const session = worktreeDragSessionRef.current - if (!point || !container || !session) { - cancelWorktreeNativeAutoscroll() - return - } - - const previousFrameTime = worktreeNativeAutoscrollLastFrameTimeRef.current ?? frameTime - worktreeNativeAutoscrollLastFrameTimeRef.current = frameTime - const autoscroll = getWorktreeSidebarDragAutoscroll({ - point, - containerRect: container.getBoundingClientRect(), - scrollTop: container.scrollTop, - scrollHeight: container.scrollHeight, - clientHeight: container.clientHeight, - elapsedMs: frameTime - previousFrameTime - }) - if (autoscroll) { - markScrollMovement() - container.scrollTop = autoscroll.scrollTop - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const drop = computeWorktreeDrop(point.clientY) - if (!drop) { - const target = getPointerDropStatusTarget({ - container, - x: point.clientX, - y: point.clientY - }) - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: point.clientY, - status: target.status, - draggedIds: session.reorderDraggedIds - }) - : null - if (statusDrop) { - setWorktreeDragState((prev) => - prev.dropIndex === statusDrop.dropIndex && - prev.dropIndicatorY === statusDrop.dropIndicatorY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - statusDrop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...statusDrop, pointerY: point.clientY } - ) - return - } - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null - } - ) - } else { - setWorktreeDragState((prev) => - prev.dropIndex === drop.dropIndex && - prev.dropIndicatorY === drop.dropIndicatorY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - drop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...drop, pointerY: point.clientY } - ) - } - } - - worktreeNativeAutoscrollFrameIdRef.current = window.requestAnimationFrame( - runWorktreeNativeAutoscrollFrame - ) - }, - [ - cancelWorktreeNativeAutoscroll, - clearWorktreeDrag, - computeWorktreeDrop, - computeWorktreeStatusDrop, - markScrollMovement, - refreshWorktreeDragSession - ] - ) - - const startWorktreeNativeAutoscroll = useCallback(() => { - if (worktreeNativeAutoscrollFrameIdRef.current !== null) { - return - } - worktreeNativeAutoscrollLastFrameTimeRef.current = null - worktreeNativeAutoscrollFrameIdRef.current = window.requestAnimationFrame( - runWorktreeNativeAutoscrollFrame - ) - }, [runWorktreeNativeAutoscrollFrame]) - - const handleWorktreeCardDragStart = useCallback( - (event: React.DragEvent, worktreeId: string, draggedIds: readonly string[]) => { - const sourceGroupKey = - worktreeDragGroups.find((group) => group.worktreeIds.includes(worktreeId))?.key ?? null - if (!sourceGroupKey) { - return - } - const reorderDraggedIds = getReorderDraggedIds(draggedIds) - const reorderUnitDraggedIds = getReorderUnitDraggedIds(sourceGroupKey, reorderDraggedIds) - const rects = scrollRef.current - ? getWorktreeSidebarDragRectsForGroup(scrollRef.current, sourceGroupKey) - : [] - const sourceRect = event.currentTarget.getBoundingClientRect() - worktreeDragSessionRef.current = { - draggingWorktreeId: worktreeId, - sourceGroupKey, - draggedIds, - reorderDraggedIds, - reorderUnitDraggedIds, - rects, - grab: getWorktreeSidebarDragGrab({ - offsetY: event.clientY - sourceRect.top, - height: sourceRect.height - }), - anchor: null - } - setWorktreeDragState({ - draggingWorktreeId: worktreeId, - sourceGroupKey, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null - }) - }, - [getReorderDraggedIds, getReorderUnitDraggedIds, worktreeDragGroups] - ) - - const handleWorktreeDragOver = useCallback( - (event: React.DragEvent) => { - const session = worktreeDragSessionRef.current - if (!session) { - return - } - worktreeNativeLatestPointRef.current = { clientX: event.clientX, clientY: event.clientY } - startWorktreeNativeAutoscroll() - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const target = getEligibleLineageDropTarget( - getPointerDropStatusTarget({ - container: event.currentTarget, - x: event.clientX, - y: event.clientY - }), - session.draggedIds - ) - if (target.lineageParentId) { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setNativeLineageDropTargetId(target.lineageParentId) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: event.clientY - } - ) - return - } - setNativeLineageDropTargetId(null) - - const drop = computeWorktreeDrop(event.clientY) - if (!drop) { - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: target.status, - draggedIds: session.reorderDraggedIds - }) - : null - if (statusDrop) { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setWorktreeDragState((prev) => - prev.dropIndex === statusDrop.dropIndex && - prev.dropIndicatorY === statusDrop.dropIndicatorY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - statusDrop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...statusDrop, pointerY: event.clientY } - ) - return - } - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null - } - ) - return - } - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setWorktreeDragState((prev) => - prev.dropIndex === drop.dropIndex && - prev.dropIndicatorY === drop.dropIndicatorY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - drop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...drop, pointerY: event.clientY } - ) - }, - [ - clearWorktreeDrag, - computeWorktreeDrop, - computeWorktreeStatusDrop, - getEligibleLineageDropTarget, - refreshWorktreeDragSession, - startWorktreeNativeAutoscroll - ] - ) - - const handleWorktreeDrop = useCallback( - (event: React.DragEvent) => { - const session = worktreeDragSessionRef.current - if (!session) { - return - } - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const boardDropTarget = getWorkspaceKanbanSidebarDropTarget(event.clientX, event.clientY) - if (boardDropTarget.status || boardDropTarget.isPinDrop) { - clearWorktreeDrag() - return - } - - const container = scrollRef.current - const target = getEligibleLineageDropTarget( - container - ? getPointerDropStatusTarget({ - container, - x: event.clientX, - y: event.clientY - }) - : { status: null, isPinDrop: false, lineageParentId: null }, - session.draggedIds - ) - - if (target.lineageParentId) { - event.preventDefault() - event.stopPropagation() - commitWorktreeLineageParentDrop(session.draggedIds, target.lineageParentId) - clearWorktreeDrag() - return - } - - const drop = computeWorktreeDrop(event.clientY) - if (!drop) { - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: target.status, - draggedIds: session.reorderDraggedIds - }) - : null - if (target.status && statusDrop) { - event.preventDefault() - event.stopPropagation() - onMoveWorktreesToStatusAtIndex({ - worktreeIds: session.reorderDraggedIds, - status: target.status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - clearWorktreeDrag() - return - } - clearWorktreeDrag() - return - } - event.preventDefault() - onReorderWorktrees({ - groups: worktreeDragGroups, - sourceGroupKey: session.sourceGroupKey, - draggedIds: session.reorderDraggedIds, - dropIndex: getFullDropIndexForWorktreeDragUnit({ - groups: worktreeDragUnitGroups, - sourceGroupKey: session.sourceGroupKey, - dropIndex: drop.dropIndex - }) - }) - clearReorderedWorktreeParents({ - draggedIds: session.draggedIds, - sourceGroupKey: session.sourceGroupKey - }) - clearWorktreeDrag() - }, - [ - clearWorktreeDrag, - clearReorderedWorktreeParents, - commitWorktreeLineageParentDrop, - computeWorktreeDrop, - computeWorktreeStatusDrop, - getEligibleLineageDropTarget, - onMoveWorktreesToStatusAtIndex, - onReorderWorktrees, - refreshWorktreeDragSession, - worktreeDragGroups, - worktreeDragUnitGroups - ] - ) - - useEffect(() => { - if (document.visibilityState !== 'visible') { - lastVisibleRefreshKeyRef.current = '__document_hidden__' - return - } - const currentWorktree = currentWorktreeId ? (worktreeMap.get(currentWorktreeId) ?? null) : null - // Why: this reporter feeds the GitHub coordinator; GitLab-only MR panels refresh via hosted-review paths. - const sidebarWorktreeHasGitHubReview = - currentWorktree !== null && - ((currentWorktree.linkedGitLabMR ?? null) === null || - (currentWorktree.linkedPR ?? null) !== null) - const shouldTrackSidebarWorktree = rightSidebarShowsPR && sidebarWorktreeHasGitHubReview - const shouldTrackVisibleRows = - groupBy === 'pr-status' || - (newCardStyle - ? cardProps.includes('status') - : cardProps.includes('pr') || cardProps.includes('ci')) - if (!shouldTrackVisibleRows && !shouldTrackSidebarWorktree) { - if (lastVisibleRefreshKeyRef.current !== '__hidden__') { - lastVisibleRefreshKeyRef.current = '__hidden__' - reportVisibleGitHubPRRefreshCandidates([], Date.now()) - } - return - } - const scrollEl = scrollRef.current - if (!scrollEl) { - return - } - const viewportTop = scrollEl.scrollTop - const viewportBottom = viewportTop + scrollEl.clientHeight - const visibleRows = virtualItems - .filter((item) => item.start < viewportBottom && item.end > viewportTop) - .map((item) => renderRows[item.index]) - .filter((row): row is WorktreeItemRow => row?.type === 'item') - .filter((row) => row.repo?.kind === 'git' && !row.worktree.isBare && row.worktree.branch) - const visibleWorktreeIds = new Set(visibleRows.map((row) => row.worktree.id)) - if ( - shouldTrackSidebarWorktree && - currentWorktree && - !currentWorktree.isBare && - currentWorktree.branch - ) { - visibleWorktreeIds.add(currentWorktree.id) - } - const visibleIdentity = visibleRows - .map((row) => `${row.worktree.id}:${row.worktree.branch}:${row.worktree.linkedPR ?? ''}`) - .join('|') - const sidebarIdentity = - shouldTrackSidebarWorktree && currentWorktree - ? `${currentWorktree.id}:${currentWorktree.branch}:${currentWorktree.linkedPR ?? ''}` - : '' - const key = `${visibleIdentity}:${sidebarIdentity}:${sshConnectedGeneration}:${prVisibleRefreshGeneration}:${cardProps.join(',')}` - if (!key || key === lastVisibleRefreshKeyRef.current) { - return - } - lastVisibleRefreshKeyRef.current = key - reportVisibleGitHubPRRefreshCandidates(Array.from(visibleWorktreeIds), Date.now()) - }, [ - cardProps, - currentWorktreeId, - documentVisibilityRevision, - groupBy, - renderRows, - reportVisibleGitHubPRRefreshCandidates, - prVisibleRefreshGeneration, - rightSidebarShowsPR, - sshConnectedGeneration, - newCardStyle, - virtualItems, - worktreeMap - ]) - - const activeDescendantId = getActiveDescendantOptionId({ - activeWorktreeId, - primaryActiveRowKey: - primaryActiveWorktreeRow?.worktreeId === activeWorktreeId - ? primaryActiveWorktreeRow.rowKey - : undefined, - pinnedDisplayPolicy, - renderRows, - virtualItems - }) - - const hasWorkspaceDropTargets = useMemo( - () => - groupBy === 'workspace-status' || - rows.some((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY), - [groupBy, rows] - ) - - const handleWorkspaceStatusDragOver = useCallback( - (event: React.DragEvent, status: WorkspaceStatus) => { - if (!hasWorkspaceDragData(event.dataTransfer)) { - return - } - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setDragOverStatus(status) - }, - [] - ) - - const handleWorkspaceStatusDragLeave = useCallback((event: React.DragEvent) => { - const relatedTarget = event.relatedTarget - if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { - return - } - setDragOverStatus(null) - }, []) - - const handleWorkspacePinDragOver = useCallback((event: React.DragEvent) => { - if (!hasWorkspaceDragData(event.dataTransfer)) { - return - } - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setPinDragOver(true) - }, []) - - const handleWorkspacePinDragLeave = useCallback((event: React.DragEvent) => { - const relatedTarget = event.relatedTarget - if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { - return - } - setPinDragOver(false) - }, []) - - const handleWorkspaceStatusDragFinish = useCallback(() => { - setDragOverStatus(null) - setPinDragOver(false) - }, []) - - const handleWorkspaceStatusDrop = useCallback( - (event: React.DragEvent, status: WorkspaceStatus) => { - const worktreeIds = readWorkspaceDragDataIds(event.dataTransfer) - if (worktreeIds.length === 0) { - return - } - event.preventDefault() - const session = worktreeDragSessionRef.current - const statusDrop = session - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status, - draggedIds: session.reorderDraggedIds - }) - : null - setDragOverStatus(null) - if (session && statusDrop) { - event.stopPropagation() - onMoveWorktreesToStatusAtIndex({ - worktreeIds: session.reorderDraggedIds, - status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - clearWorktreeDrag() - return - } - // Match status-drop scope to drag-preview scope (#9083): session uses its expanded set, else expand dataTransfer ids live. - onMoveWorktreesToStatus( - session ? session.reorderDraggedIds : getReorderDraggedIds(worktreeIds), - status - ) - }, - [ - clearWorktreeDrag, - computeWorktreeStatusDrop, - getReorderDraggedIds, - onMoveWorktreesToStatus, - onMoveWorktreesToStatusAtIndex, - worktreeDragGroups - ] - ) - - useEffect(() => { - const handleDocumentDrop = (event: DragEvent): void => { - const session = worktreeDragSessionRef.current - if (!session) { - return - } - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const drop = computeWorktreeDrop(event.clientY) - if (!drop) { - const container = scrollRef.current - const target = getEligibleLineageDropTarget( - container - ? getPointerDropStatusTarget({ - container, - x: event.clientX, - y: event.clientY - }) - : { status: null, isPinDrop: false, lineageParentId: null }, - session.draggedIds - ) - if (target.lineageParentId) { - event.preventDefault() - event.stopPropagation() - commitWorktreeLineageParentDrop(session.draggedIds, target.lineageParentId) - clearWorktreeDrag() - return - } - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: target.status, - draggedIds: session.reorderDraggedIds - }) - : null - if (target.status && statusDrop) { - event.preventDefault() - event.stopPropagation() - onMoveWorktreesToStatusAtIndex({ - worktreeIds: session.reorderDraggedIds, - status: target.status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - clearWorktreeDrag() - return - } - clearWorktreeDrag() - return - } - // Why: pointer still inside the source group means reorder, not status move; commit here and stop the capture handler. - event.preventDefault() - event.stopPropagation() - onReorderWorktrees({ - groups: worktreeDragGroups, - sourceGroupKey: session.sourceGroupKey, - draggedIds: session.reorderDraggedIds, - dropIndex: getFullDropIndexForWorktreeDragUnit({ - groups: worktreeDragUnitGroups, - sourceGroupKey: session.sourceGroupKey, - dropIndex: drop.dropIndex - }) - }) - clearReorderedWorktreeParents({ - draggedIds: session.draggedIds, - sourceGroupKey: session.sourceGroupKey - }) - clearWorktreeDrag() - } - - document.addEventListener('drop', handleDocumentDrop, true) - return () => document.removeEventListener('drop', handleDocumentDrop, true) - }, [ - clearWorktreeDrag, - clearReorderedWorktreeParents, - commitWorktreeLineageParentDrop, - computeWorktreeDrop, - computeWorktreeStatusDrop, - getEligibleLineageDropTarget, - onMoveWorktreesToStatusAtIndex, - onReorderWorktrees, - refreshWorktreeDragSession, - worktreeDragGroups, - worktreeDragUnitGroups - ]) - - useEffect(() => { - const handleDocumentDragEnd = (): void => { - if (worktreeDragSessionRef.current) { - clearWorktreeDrag() - } - } - - document.addEventListener('dragend', handleDocumentDragEnd, true) - return () => document.removeEventListener('dragend', handleDocumentDragEnd, true) - }, [clearWorktreeDrag]) - - useEffect(() => { - const handleVisibilityChange = (): void => { - if (document.visibilityState !== 'visible' && worktreeDragSessionRef.current) { - clearWorktreeDrag() - } - } - - document.addEventListener('visibilitychange', handleVisibilityChange) - return () => document.removeEventListener('visibilitychange', handleVisibilityChange) - }, [clearWorktreeDrag]) - - // Why: expand here (not the shared hook, used by the flat board) so a dropped parent carries its lineage children (#9083). - const moveWorktreesToStatusForDocumentDrop = useCallback( - (ids: readonly string[], status: WorkspaceStatus) => - onMoveWorktreesToStatus(getReorderDraggedIds(ids), status), - [getReorderDraggedIds, onMoveWorktreesToStatus] - ) - - useWorkspaceStatusDocumentDrop( - scrollRef, - onMoveWorktreeToStatus, - onPinWorktree, - handleWorkspaceStatusDragFinish, - hasWorkspaceDropTargets, - { - onMoveWorktreesToStatus: moveWorktreesToStatusForDocumentDrop, - onPinWorktrees - } - ) - - return ( -
-
-
- {canReorderRepoHeaders && - repoDrag.state.draggingRepoId !== null && - repoDrag.state.dropIndicatorY !== null ? ( - - ) : null} - {canReorderProjectGroupHeaders && - projectGroupDrag.state.draggingGroupId !== null && - projectGroupDrag.state.dropIndicatorY !== null ? ( - - ) : null} - {hostDrag.state.draggingHostId !== null && hostDrag.state.dropIndicatorY !== null ? ( - - ) : null} - {worktreeDragState.draggingWorktreeId !== null && - worktreeDragState.dropIndicatorY !== null ? ( - - ) : null} - {virtualItems.map((vItem) => { - const row = renderRows[vItem.index] - if (!row) { - return null - } - - if (row.type === 'host-header') { - // Why: the host card is the outer tier; it pins above group headers (z-30 vs z-20) and stays put as they hand off. - const isActiveStickyHost = activeStickyHostIndexRef.current === vItem.index - const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ - rows: renderRows, - index: vItem.index, - firstHeaderIndex - }) - return ( -
- toggleGroupWithScrollAnchor(row.key)} - onDragPointerDown={ - orderedHostIds.length > 1 - ? (e) => hostDrag.onHandlePointerDown(e, row.hostId) - : undefined - } - dragging={hostDrag.state.draggingHostId === row.hostId} - /> -
- ) - } - - if (row.type === 'header') { - const isActiveStickyHeader = activeStickyHeaderIndexRef.current === vItem.index - // Why: when a host card is pinned, the group tier pins flush beneath it, not at the viewport top. - const stickyTopClass = - activeStickyHostIndexRef.current !== null ? 'top-[35px]' : '-top-px' - const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ - rows: renderRows, - index: vItem.index, - firstHeaderIndex - }) - const isRepoHeader = groupBy === 'repo' && row.repo !== undefined - const isProjectGroupHeader = groupBy === 'repo' && row.projectGroup !== undefined - const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined - const projectGroupIdForHeader = - isProjectGroupHeader && !row.repo && typeof row.projectGroup?.id === 'string' - ? row.projectGroup.id - : undefined - const repoHeaderIndex = - projectIdForHeader !== undefined - ? repoHeaderIndexByRepoId.get(projectIdForHeader) - : undefined - const repoHeaderBucketKey = - projectIdForHeader !== undefined - ? repoHeaderBucketByRepoId.get(projectIdForHeader) - : undefined - const projectGroupHeaderIndex = - projectGroupIdForHeader !== undefined - ? projectGroupHeaderIndexByGroupId.get(projectGroupIdForHeader) - : undefined - const projectGroupHeaderBucketKey = - projectGroupIdForHeader !== undefined - ? projectGroupHeaderBucketByGroupId.get(projectGroupIdForHeader) - : undefined - const isDraggableRepoHeader = Boolean( - canReorderRepoHeaders && - isRepoHeader && - projectIdForHeader && - repoHeaderBucketKey && - (sidebarRepoHeaderIdsByBucket.get(repoHeaderBucketKey)?.length ?? 0) > 1 - ) - const isDraggableProjectGroupHeader = Boolean( - canReorderProjectGroupHeaders && - projectGroupIdForHeader && - projectGroupHeaderBucketKey && - (sidebarProjectGroupHeaderIdsByBucket.get(projectGroupHeaderBucketKey)?.length ?? - 0) > 1 - ) - const isDraggingThis = - canReorderRepoHeaders && - repoDrag.state.draggingRepoId !== null && - repoDrag.state.draggingRepoId === projectIdForHeader - const isDraggingThisProjectGroup = - canReorderProjectGroupHeaders && - projectGroupDrag.state.draggingGroupId !== null && - projectGroupDrag.state.draggingGroupId === projectGroupIdForHeader - const headerWorkspaceStatus = - groupBy === 'workspace-status' - ? getWorkspaceStatusFromGroupKey(row.key, workspaceStatuses) - : null - const isPinnedHeader = row.key === PINNED_GROUP_KEY - const repoHeaderColor = resolveProjectGroupHeaderColor({ - groupBy, - headerKey: row.key, - badgeColor: row.repo?.badgeColor - }) - const createState = row.repo - ? getRepoHeaderCreateState({ - repo: row.repo, - label: row.label, - sshStatus: row.repo.connectionId - ? (sshConnectionStates.get(row.repo.connectionId)?.status ?? null) - : null - }) - : null - const projectGroupPathStatus = - isProjectGroupHeader && - row.projectGroup && - 'parentPath' in row.projectGroup && - row.projectGroup.parentPath - ? getCachedFolderWorkspacePathStatus({ - scope: 'project-group', - projectGroupId: row.projectGroup.id - }) - : null - const folderWorkspaceCreateDisabled = - projectGroupPathStatus?.exists === false && - (isConfirmedStaleFolderPathStatus(projectGroupPathStatus) || - projectGroupPathStatus.reason === 'ambiguous-connection') - const projectGroupDepth = row.projectGroupDepth ?? 0 - const isHeaderCollapsed = collapsedGroups.has(row.key) - // Why: repo/project/status/pinned share compact section chrome; flat "All" stays a simple label. - const showHeaderCollapseAffordance = - row.count > 0 && - (isRepoHeader || - isProjectGroupHeader || - headerWorkspaceStatus !== null || - isPinnedHeader) - // Why: non-project headers like "All" are flat-list labels; don't reserve project hierarchy indent. - const headerPaddingLeft = - isRepoHeader || isProjectGroupHeader - ? getProjectGroupHeaderPaddingLeft(projectGroupDepth) - : WORKTREE_SECTION_HEADER_PADDING_LEFT - return ( -
-
handleWorkspaceStatusDragOver(event, headerWorkspaceStatus) - : undefined - } - onDragLeave={ - isPinnedHeader - ? handleWorkspacePinDragLeave - : headerWorkspaceStatus - ? handleWorkspaceStatusDragLeave - : undefined - } - onDrop={ - headerWorkspaceStatus - ? (event) => handleWorkspaceStatusDrop(event, headerWorkspaceStatus) - : undefined - } - onPointerDown={ - isDraggableRepoHeader && projectIdForHeader - ? (event) => repoDrag.onHandlePointerDown(event, projectIdForHeader) - : isDraggableProjectGroupHeader && projectGroupIdForHeader - ? (event) => - projectGroupDrag.onHandlePointerDown(event, projectGroupIdForHeader) - : undefined - } - onClick={(event) => { - if (shouldIgnoreRepoHeaderToggle(event)) { - return - } - toggleGroupWithScrollAnchor(row.key) - }} - onKeyDown={(e) => { - if (shouldIgnoreRepoHeaderToggle(e)) { - return - } - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - toggleGroupWithScrollAnchor(row.key) - } - }} - > - {/* Why: grab cursor on icon+title only. Row still has handle attrs so - indent/padding can arm drag; actions are excluded via data-repo-header-actions. - self-stretch fills h-7 so grab matches the full title column height. */} -
- {row.icon ? ( -
- {row.repo ? ( - - ) : ( - - )} -
- ) : null} - -
-
-
- {row.label} -
- - -
-
-
- - - {showHeaderCollapseAffordance ? ( -
{ - event.preventDefault() - event.stopPropagation() - toggleGroupWithScrollAnchor(row.key) - }} - > - -
- ) : null} - - {isProjectGroupHeader && !row.repo && row.projectGroup?.id ? ( - - - - - - { - if (row.projectGroup?.id) { - handleRenameProjectGroup(row.projectGroup.id, row.label) - } - }} - > - {translate( - 'auto.components.sidebar.WorktreeList.4d7b73658c', - 'Rename group' - )} - - { - if (row.projectGroup?.id) { - handleDeleteProjectGroup(row.projectGroup.id, row.label) - } - }} - > - {translate( - 'auto.components.sidebar.WorktreeList.902115cdbe', - 'Delete group' - )} - - - - ) : null} - - {isProjectGroupHeader && - !row.repo && - row.projectGroup && - 'parentPath' in row.projectGroup && - row.projectGroup.parentPath ? ( - - - - - - {projectGroupPathStatus?.exists === false - ? getFolderWorkspacePathStatusDescription(projectGroupPathStatus) - : translate( - 'auto.components.sidebar.WorktreeList.bd37a57ac8', - 'Create workspace for {{value0}}', - { value0: row.label } - )} - - - ) : null} - - {row.repo && groupBy === 'repo' ? ( - - - - - - - - - {translate( - 'auto.components.sidebar.WorktreeList.2ef41bf9a7', - 'Project actions' - )} - - - - { - if (row.repo) { - handleOpenRepoSettings(row.repo.id) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.2cdffbc728', - 'Project Settings' - )} - - { - if (row.repo) { - handleOpenRepoSettings( - row.repo.id, - getRepositoryIconSectionId(row.repo.id) - ) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.e82d3589a1', - 'Change Project Icon' - )} - - {row.repo && isGitRepoKind(row.repo) ? ( - { - if (row.repo) { - handleOpenWorktreeVisibility(row.repo) - } - }} - > - - {getWorktreeVisibilityMenuLabel(row.repo)} - - ) : null} - { - if (row.repo) { - handleCreateGroupFromRepo(row.repo) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.cbfd565f83', - 'New group from project' - )} - - {projectGroups.length > 0 ? ( - - - - {translate( - 'auto.components.sidebar.WorktreeList.4a08fb55f2', - 'Move to group' - )} - - - {projectGroups.map((group) => ( - { - if (row.repo) { - handleMoveProjectToGroup(row.repo, group.id) - } - }} - > - {group.name} - - ))} - - - ) : null} - {row.repo.projectGroupId ? ( - { - if (row.repo) { - handleRemoveProjectFromGroup(row.repo) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.64e55f7f01', - 'Remove from group' - )} - - ) : null} - - { - if (row.repo) { - handleRemoveProject(row.repo) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.c83968f87f', - 'Remove Project' - )} - - - - ) : null} - - {row.repo && groupBy === 'repo' ? ( - - - {createState?.disabled ? ( - event.stopPropagation()} - onPointerDown={handleRepoHeaderActionPointerDown} - > - - - ) : ( - - )} - - - {createState?.tooltip ?? - translate( - 'auto.components.sidebar.WorktreeList.bb85cd86ba', - 'Create workspace for {{value0}}', - { value0: row.label } - )} - - - ) : null} -
-
-
- ) - } - - const renderWorktreeRow = ( - itemRow: WorktreeItemRow, - nested: boolean, - lineageChildren?: React.ReactNode, - forceActiveSurface = false - ) => { - const lineageToggleGroupKey = itemRow.lineageGroupKey - const experimentalNewWorktreeCardStyle = - settings?.experimentalNewWorktreeCardStyle === true - const projectGroupId = itemRow.repo?.projectGroupId - const isFolderBackedRepoChild = - groupBy === 'repo' && - Boolean(projectGroupId && folderBackedProjectGroupIds.has(projectGroupId)) - // Why: experimental in-card lineage inherits the parent surface; legacy cards keep depth-based nested geometry. - const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth - const getCardContentIndent = (lineageDepth: number): number => - isFolderBackedRepoChild - ? getFolderBackedRepoWorktreeCardContentIndent({ - groupDepth: itemRow.groupDepth, - lineageDepth - }) - : getWorktreeCardContentIndent({ - isGrouped: groupBy !== 'none', - groupDepth: itemRow.groupDepth, - lineageDepth - }) - const inheritedCardContentIndent = getCardContentIndent(0) - const nestedLineageGeometry = nested - ? getLineageNestedRowGeometry({ - experimentalNewWorktreeCardStyle, - inheritedCardContentIndent, - lineageDepth: itemRow.depth - }) - : null - // Why: grouped rows inherit their header depth, but the card surface still spans the full row. - const paddingLeft = - nested && groupBy !== 'none' - ? getWorktreeCardContentIndent({ - isGrouped: false, - groupDepth: itemRow.groupDepth, - lineageDepth: paddingDepth - }) - : getCardContentIndent(paddingDepth) - const surfaceInset = nested - ? nestedLineageGeometry!.surfaceInset - : isFolderBackedRepoChild - ? getFolderBackedRepoWorktreeCardSurfaceInset({ - groupDepth: itemRow.groupDepth, - lineageDepth: paddingDepth - }) - : getWorktreeCardSurfaceInset({ - isGrouped: groupBy !== 'none', - groupDepth: itemRow.groupDepth - }) - const cardContentIndent = nested - ? nestedLineageGeometry!.cardContentIndent - : Math.max(0, paddingLeft - surfaceInset) - const lineageChildrenStyle = lineageChildren - ? getLineageChildrenInlineStyle( - nestedLineageGeometry?.lineageChildrenInlineOffset ?? - LINEAGE_CHILDREN_INLINE_OFFSET - ) - : undefined - const worktreeDragGroupKey = groupKeyByRowKey.get(itemRow.rowKey) - const worktreeDragGroupIndex = groupIndexByRowKey.get(itemRow.rowKey) - const revealHighlightTone = - agentSendTargetWorktreeId === itemRow.worktree.id ? 'ai' : 'default' - const isLineageDropTarget = - worktreeDragState.draggingWorktreeId && - (worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId === - itemRow.worktree.id || - nativeLineageDropTargetId === itemRow.worktree.id) - const isPinnedOverlayRow = itemRow.sectionKey === PINNED_GROUP_KEY - const isActiveWorktree = activeWorktreeId === itemRow.worktree.id - const activeSurfaceVariant = getActiveSurfaceVariant(itemRow) - return ( -
{ - if (nested) { - event.stopPropagation() - } - handleWorktreeRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) - }} - style={{ - paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined - }} - > - -
- ) - } - - const renderLineageDescendants = ( - parent: WorktreeItemRow, - descendants: readonly WorktreeItemRow[] - ): React.ReactNode | undefined => { - const childNodes: React.ReactNode[] = [] - let cursor = 0 - while (cursor < descendants.length) { - const child = descendants[cursor] - if (!child || child.depth !== parent.depth + 1) { - cursor++ - continue - } - - let nextSiblingIndex = cursor + 1 - while ( - nextSiblingIndex < descendants.length && - descendants[nextSiblingIndex]!.depth > child.depth - ) { - nextSiblingIndex++ - } - - const childLineageChildren = renderLineageDescendants( - child, - descendants.slice(cursor + 1, nextSiblingIndex) - ) - childNodes.push(renderWorktreeRow(child, true, childLineageChildren)) - cursor = nextSiblingIndex - } - return childNodes.length > 0 ? childNodes : undefined - } - - if (row.type === 'lineage-group') { - const [parent, ...children] = row.rows - const childIsActive = children.some((child) => child.worktree.id === activeWorktreeId) - const parentPreviewOffset = parent - ? (worktreeDragState.previewOffsetsByWorktreeId.get(parent.worktree.id) ?? 0) - : 0 - return ( -
-
- {parent - ? renderWorktreeRow( - parent, - false, - renderLineageDescendants(parent, children), - childIsActive - ) - : null} -
-
- ) - } - - if (row.type === 'imported-worktrees-card') { - const actionState = importedWorktreeCardActionState.get(row.repo.id) - return ( -
- handleShowImportedWorktrees(row.repo.id)} - onKeepHidden={ - canKeepImportedWorktreesHidden(row, actionState) - ? () => handleKeepImportedWorktreesHidden(row.repo.id) - : undefined - } - /> -
- ) - } - - if (row.type === 'new-external-worktrees-inbox') { - const actionState = newExternalWorktreeInboxActionState.get(row.repo.id) - return ( -
- handleOpenWorktreeVisibility(row.repo)} - onSuppress={() => handleOpenSuppressExternalWorktreeInbox(row.repo.id)} - /> -
- ) - } - - if (row.type === 'pending-creation') { - return ( -
- -
- ) - } - - if (row.type === 'folder-workspace') { - const folderWorkspaceRow = row as FolderWorkspaceItemRow - const folderWorktree = folderWorkspaceToWorktree(folderWorkspaceRow.folderWorkspace) - const folderWorkspacePathStatus = getCachedFolderWorkspacePathStatus({ - scope: 'folder-workspace', - folderWorkspaceId: folderWorkspaceRow.folderWorkspace.id - }) - const folderWorkspaceActivationDisabled = - folderWorkspacePathStatus?.exists === false && - (isConfirmedStaleFolderPathStatus(folderWorkspacePathStatus) || - folderWorkspacePathStatus.reason === 'ambiguous-connection') - const folderPrDisplay = getFolderWorkspaceCardPrDisplay({ - folderWorkspaceId: folderWorkspaceRow.folderWorkspace.id, - workspaceLineageByChildKey, - worktreeLineageById, - worktreeMap, - repoMap, - hostedReviewCache, - prCache, - settings - }) - const isFolderBackedWorkspaceChild = - groupBy === 'repo' && folderWorkspaceRow.projectGroup.createdFrom === 'folder-scan' - const { surfaceInset, cardContentIndent } = getFolderWorkspaceRowGeometry({ - experimentalNewWorktreeCardStyle: newCardStyle, - isFolderBackedWorkspaceChild, - isGrouped: groupBy !== 'none', - groupDepth: folderWorkspaceRow.groupDepth, - lineageDepth: folderWorkspaceRow.depth - }) - return ( -
- handleWorktreeRowPointerDown(event, folderWorktree.id, folderWorktree.id) - } - > -
0 ? { paddingLeft: surfaceInset } : undefined} - > - -
- -
-
-
- ) - } - - const itemWorkspaceStatus = - groupBy === 'workspace-status' - ? getWorkspaceStatus(row.worktree, workspaceStatuses) - : null - const itemPreviewOffset = - worktreeDragState.previewOffsetsByWorktreeId.get(row.worktree.id) ?? 0 - - return ( -
handleWorkspaceStatusDragOver(event, itemWorkspaceStatus) - : undefined - } - onDragLeave={itemWorkspaceStatus ? handleWorkspaceStatusDragLeave : undefined} - onDrop={ - itemWorkspaceStatus - ? (event) => handleWorkspaceStatusDrop(event, itemWorkspaceStatus) - : undefined - } - > - {renderWorktreeRow(row, false)} -
- ) - })} -
-
- {showScrollToTop ? : null} -
- ) -}) + EMPTY_PROJECT_GROUPS, + NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK +} from './worktree-list/virtualized-worktree-viewport-props' +import { useAgentSendTargetWorktreeId } from './worktree-list/use-agent-send-target-worktree' +import { useEffectiveCollapsedGroups } from './worktree-list/use-effective-collapsed-groups' +import { useProjectGroupDialogs } from './worktree-list/use-project-group-dialogs' +import { useSidebarExternalWorktreeCards } from './worktree-list/use-sidebar-external-worktree-cards' +import { useSidebarHostVisibleScope } from './worktree-list/use-sidebar-host-visible-scope' +import { useSidebarRevealRequests } from './worktree-list/use-sidebar-reveal-requests' +import { useSidebarSectionRows } from './worktree-list/use-sidebar-section-rows' +import { useSidebarWorktreeFilters } from './worktree-list/use-sidebar-worktree-filters' +import { useSidebarWorktreeSelection } from './worktree-list/use-sidebar-worktree-selection' +import { useSidebarWorktreeSortOrder } from './worktree-list/use-sidebar-worktree-sort-order' +import { useVisibleSidebarWorktrees } from './worktree-list/use-visible-sidebar-worktrees' +import { useWorktreeStatusMutations } from './worktree-list/use-worktree-status-mutations' type WorktreeListProps = { scrollOffsetRef: React.MutableRefObject @@ -5213,11 +47,6 @@ type WorktreeListProps = { onWorkspaceBoardDragPreviewCancel?: () => void } -export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void { - document.addEventListener('visibilitychange', onChange) - return () => document.removeEventListener('visibilitychange', onChange) -} - const WorktreeList = React.memo(function WorktreeList({ scrollOffsetRef, scrollAnchorRef, @@ -5230,9 +59,9 @@ const WorktreeList = React.memo(function WorktreeList({ const allWorktrees = useAllWorktrees() const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() + const repos = useAppStore((s) => s.repos) const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) @@ -5241,365 +70,29 @@ const WorktreeList = React.memo(function WorktreeList({ [activeWorkspaceKey, activeWorktreeId] ) const groupBy = useAppStore((s) => s.groupBy) - const setGroupBy = useAppStore((s) => s.setGroupBy) - const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) - const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) - const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder) - const setWorkspaceHostOrder = useAppStore((s) => s.setWorkspaceHostOrder) const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const sortBy = useAppStore((s) => s.sortBy) - const setSortBy = useAppStore((s) => s.setSortBy) const projectOrderBy = useAppStore((s) => s.projectOrderBy) - const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) - const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0)) - const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) - const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) - const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) - const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) - const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) - const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) - const filterRepoIds = useAppStore((s) => s.filterRepoIds) const openModal = useAppStore((s) => s.openModal) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) - const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) - const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta) - const updateRepo = useAppStore((s) => s.updateRepo) - const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) const activeView = useAppStore((s) => s.activeView) const activeModal = useAppStore((s) => s.activeModal) const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree) const pendingRevealSidebarRow = useAppStore((s) => s.pendingRevealSidebarRow) - const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) - const revealSidebarRow = useAppStore((s) => s.revealSidebarRow) - const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal) const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId) const clearPendingRevealSidebarRow = useAppStore((s) => s.clearPendingRevealSidebarRow) - const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) - // Why: eligibility only matters while the picker is open; when closed, don't subscribe to wake-time layout churn. - const agentTargetStatusByPaneKey = useAppStore((s) => - agentSendPopoverTargetMode ? s.agentStatusByPaneKey : EMPTY_AGENT_STATUS_BY_PANE_KEY - ) - const agentTargetStatusEpoch = useAppStore((s) => - agentSendPopoverTargetMode ? s.agentStatusEpoch : 0 - ) - const agentTargetTabsByWorktree = useAppStore((s) => - agentSendPopoverTargetMode ? s.tabsByWorktree : EMPTY_TABS_BY_WORKTREE - ) - const agentTargetTerminalLayoutsByTabId = useAppStore((s) => - agentSendPopoverTargetMode ? s.terminalLayoutsByTabId : EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID - ) - const agentTargetPtyIdsByTabId = useAppStore((s) => - agentSendPopoverTargetMode ? s.ptyIdsByTabId : EMPTY_PTY_IDS_BY_TAB_ID - ) - const agentTargetRuntimePaneTitlesByTabId = useAppStore((s) => - agentSendPopoverTargetMode ? s.runtimePaneTitlesByTabId : EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID - ) - const agentSendTargetWorktreeId = useMemo(() => { - void agentTargetStatusEpoch - if (!agentSendPopoverTargetMode) { - return null - } - const targets = deriveRunningAgentSendTargets( - { - agentStatusByPaneKey: agentTargetStatusByPaneKey, - tabsByWorktree: agentTargetTabsByWorktree, - terminalLayoutsByTabId: agentTargetTerminalLayoutsByTabId, - ptyIdsByTabId: agentTargetPtyIdsByTabId, - runtimePaneTitlesByTabId: agentTargetRuntimePaneTitlesByTabId - }, - agentSendPopoverTargetMode.worktreeId - ) - return targets.some((target) => target.status === 'eligible') - ? agentSendPopoverTargetMode.worktreeId - : null - }, [ - // Why: eligibility can flip when the stale-boundary scheduler bumps this epoch without replacing the status map. - agentTargetStatusEpoch, - agentSendPopoverTargetMode, - agentTargetStatusByPaneKey, - agentTargetTabsByWorktree, - agentTargetTerminalLayoutsByTabId, - agentTargetPtyIdsByTabId, - agentTargetRuntimePaneTitlesByTabId - ]) - - // Read tabsByWorktree when needed for filtering or sorting - const needsActivityMaps = !showSleepingWorkspaces || sortBy === 'smart' - const tabsByWorktree = useAppStore((s) => - needsActivityMaps ? getVisibleWorktreeTerminalActivityTabs(s.tabsByWorktree) : null - ) - const ptyIdsByTabId = useAppStore((s) => (needsActivityMaps ? s.ptyIdsByTabId : null)) - const browserTabsByWorktree = useAppStore((s) => - !showSleepingWorkspaces ? getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree) : null - ) - + const collapsedGroups = useAppStore((s) => s.collapsedGroups) + const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup) + const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS) + const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const settings = useAppStore((s) => s.settings) const cardProps = useAppStore((s) => s.worktreeCardProperties) - const { prCache, hostedReviewCache } = useAppStore( useShallow((s) => selectWorktreeListReviewCacheInputs(s, groupBy, cardProps)) ) - const settings = useAppStore((s) => s.settings) const pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings) - const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) - const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) - const pairedDeviceIdsByEnvironment = useMemo( - () => - hideWorkspacesFromOtherDevices - ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) - : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, - [hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId] - ) - - const sortEpoch = useAppStore((s) => s.sortEpoch) - - // Non-archived count — detects structural changes (add/remove) so the debounce below can apply immediately. - const worktreeCount = useMemo(() => { - let count = 0 - for (const worktree of allWorktrees) { - if (!worktree.isArchived) { - count++ - } - } - return count - }, [allWorktrees]) - - // Why debounce: scores are time-decaying, so recomputing on every sortEpoch bump makes worktrees jump; settle to coalesce. - // Structural changes (add/remove) bypass the debounce so a new worktree appears at its sorted position immediately. - const [debouncedSortEpoch, setDebouncedSortEpoch] = useState(sortEpoch) - const prevWorktreeCountRef = useRef(worktreeCount) - useEffect(() => { - if (debouncedSortEpoch === sortEpoch) { - return - } - - const structuralChange = worktreeCount !== prevWorktreeCountRef.current - prevWorktreeCountRef.current = worktreeCount - - // Why: manual drag/drop is direct manipulation; the settle-window delay would make a successful drop look broken. - if (structuralChange || sortBy === 'manual') { - setDebouncedSortEpoch(sortEpoch) - return - } - - const timer = setTimeout(() => setDebouncedSortEpoch(sortEpoch), SORT_SETTLE_MS) - return () => clearTimeout(timer) - }, [sortEpoch, debouncedSortEpoch, worktreeCount, sortBy]) - - // Why a latching ref: a live signal makes Smart authoritative for the session, even after that activity ends. - const sessionHasHadLiveSmartSignal = useRef(false) - - // ── Stable sort order ────────────────────────────────────────── - // Why sortEpoch (not selection): selection side-effects (clearing isUnread, PR-cache refresh) must not reorder the sidebar under the user. - // Why useMemo not useEffect: order must be computed synchronously before the worktrees memo reads it. - // Why a ref alongside the memo: telemetry effects need the last attention map without re-reading store state. - const lastAttentionByWorktreeRef = useRef | null>(null) - - const recomputedSortedIds = useMemo(() => { - const state = useAppStore.getState() - const nonArchivedWorktrees = getAllWorktreesFromState(state).filter( - (worktree) => !worktree.isArchived - ) - const now = Date.now() - - // Why cold-start detection: agent-status hydrates async, so the warm comparator would collapse all to Class 4; keep the persisted order until a live signal appears. - if (sortBy === 'smart' && !sessionHasHadLiveSmartSignal.current) { - // Why tabHasLivePty over tab.ptyId: slept terminals keep tab.ptyId as a wake hint, so it'd falsely keep cold-start ordering off. - const hasAnyLivePty = Object.values(state.tabsByWorktree) - .flat() - .some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) - if ( - hasAnyLivePty || - hasFreshAttributedAgentStatus(state.agentStatusByPaneKey, now, state.tabsByWorktree) - ) { - sessionHasHadLiveSmartSignal.current = true - } else { - nonArchivedWorktrees.sort( - (a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b) - ) - lastAttentionByWorktreeRef.current = null - return nonArchivedWorktrees.map((w) => w.id) - } - } - - const currentTabs = state.tabsByWorktree - // Why precompute: hot sort — build the attention map once so the O(N log N) comparator does O(1) lookups. - const attentionByWorktree = - sortBy === 'smart' - ? buildAttentionByWorktree( - nonArchivedWorktrees, - currentTabs, - state.agentStatusByPaneKey, - state.runtimePaneTitlesByTabId, - state.ptyIdsByTabId, - now, - state.migrationUnsupportedByPtyId, - state.terminalLayoutsByTabId - ) - : new Map() - lastAttentionByWorktreeRef.current = sortBy === 'smart' ? attentionByWorktree : null - nonArchivedWorktrees.sort(buildWorktreeComparator(sortBy, repoMap, now, attentionByWorktree)) - return nonArchivedWorktrees.map((w) => w.id) - // debouncedSortEpoch is an intentional trigger not read in the memo; its change (debounced) signals a recompute. - // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSortEpoch, repoMap, sortBy]) - // Why: stable ID order prevents rank-only refreshes from echoing an unchanged snapshot. - const sortedIds = useReusedArrayIdentity(recomputedSortedIds) - - // Why a ref of prior class: fire class_1_promotion only on transitions into Class 1, not every recompute that stays there. - const prevClassByWorktreeIdRef = useRef>(new Map()) - // Why gate the first observation: an empty prev-class map makes every existing Class-1 worktree look freshly promoted; treat the first pass as a silent baseline. - const hasObservedSmartOnceRef = useRef(false) - - useEffect(() => { - const attention = lastAttentionByWorktreeRef.current - if (sortBy !== 'smart' || !attention) { - // Why reset: leaving Smart drops the prior-class map (and first-observation gate) so re-entry doesn't fire stale promotions. - prevClassByWorktreeIdRef.current = new Map() - hasObservedSmartOnceRef.current = false - return - } - const next = new Map() - const isFirstObservation = !hasObservedSmartOnceRef.current - for (const [worktreeId, info] of attention) { - const prev = prevClassByWorktreeIdRef.current.get(worktreeId) - if (!isFirstObservation && info.cls === 1 && prev !== 1 && info.cause) { - track('smart_sort_class_1_promotion', { cause: info.cause }) - } - next.set(worktreeId, info.cls) - } - prevClassByWorktreeIdRef.current = next - hasObservedSmartOnceRef.current = true - }, [sortBy, recomputedSortedIds]) - - // Why retry on recomputation: Smart may activate before attention hydrates; fire once, then stay quiet until the user leaves Smart. - const hasTrackedSmartDistributionRef = useRef(false) - useEffect(() => { - if (sortBy !== 'smart') { - hasTrackedSmartDistributionRef.current = false - return - } - if (hasTrackedSmartDistributionRef.current) { - return - } - const attention = lastAttentionByWorktreeRef.current - if (!attention || attention.size === 0) { - return - } - let class1 = 0 - let class2 = 0 - let class3 = 0 - let class4 = 0 - for (const info of attention.values()) { - if (info.cls === 1) { - class1++ - } else if (info.cls === 2) { - class2++ - } else if (info.cls === 3) { - class3++ - } else { - class4++ - } - } - track('smart_sort_class_distribution', { - class_1: class1, - class_2: class2, - class_3: class3, - class_4: class4, - total_worktrees: attention.size - }) - hasTrackedSmartDistributionRef.current = true - }, [sortBy, recomputedSortedIds]) - - // Why fire on the transition: switching away from Smart is the signal; compare via ref so a round-trip doesn't double-fire. - const prevSortByRef = useRef(sortBy) - useEffect(() => { - const prev = prevSortByRef.current - prevSortByRef.current = sortBy - if (prev === 'smart' && sortBy === 'recent') { - track('smart_to_recent_switch', {}) - } - }, [sortBy]) - - // Why: only persist during live sessions so cold start reads the persisted order instead of overwriting it. - useEffect(() => { - if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadLiveSmartSignal.current) { - return - } - // Why: sortOrder lives in each host's worktreeMeta, so persist each host's ids on that host. - const state = useAppStore.getState() - persistWorktreeSortOrderByHost(state, sortedIds) - }, [sortedIds, sortBy]) - - // Flatten/filter/sort via the shared utility so card order matches Cmd+1–9 numbering. - const recomputedVisibleWorktrees = useMemo(() => { - void agentStatusEpoch - const ids = computeVisibleWorktreeIds(worktreesByRepo, sortedIds, { - filterRepoIds, - showSleepingWorkspaces, - tabsByWorktree, - ptyIdsByTabId, - browserTabsByWorktree, - // Why snapshot on agentStatusEpoch: update membership immediately without repainting on every hook ping. - worktreeIdsWithLiveAgent: showSleepingWorkspaces - ? EMPTY_WORKTREE_ID_SET - : getWorktreeIdsWithLiveAgent( - useAppStore.getState().agentStatusByPaneKey, - tabsByWorktree, - Date.now() - ), - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - pairedDeviceIdsByEnvironment, - alwaysShowDefaultBranchWorkspace, - repoMap, - workspaceHostScope, - visibleWorkspaceHostIds, - defaultHostId: getSettingsFocusedExecutionHostId(settings), - worktreeLineageById, - forcedVisibleWorktreeIds: agentSendTargetWorktreeId ? [agentSendTargetWorktreeId] : undefined - }) - return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null) - }, [ - agentSendTargetWorktreeId, - agentStatusEpoch, - filterRepoIds, - showSleepingWorkspaces, - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - alwaysShowDefaultBranchWorkspace, - workspaceHostScope, - visibleWorkspaceHostIds, - settings, - repoMap, - tabsByWorktree, - ptyIdsByTabId, - browserTabsByWorktree, - sortedIds, - worktreeMap, - worktreeLineageById, - worktreesByRepo, - pairedDeviceIdsByEnvironment - ]) - // Why: agentStatusEpoch bumps recompute this memo even when membership and - // order are unchanged; keeping the previous identity stops the whole - // rows/sectionRows/renderedWorktrees chain from churning per epoch. - const visibleWorktrees = useReusedArrayIdentity(recomputedVisibleWorktrees) - - const worktrees = visibleWorktrees - const collapsedGroups = useAppStore((s) => s.collapsedGroups) - const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup) - - // Why: manual header order is bound to state.repos; Recent/Smart derive order from the sorted worktree stream. - const repos = useAppStore((s) => s.repos) + const defaultHostId = getSettingsFocusedExecutionHostId(settings) const projectHostSetupProjection = useProjectHostSetupProjection() const projectGrouping = useMemo( () => ({ @@ -5608,427 +101,89 @@ const WorktreeList = React.memo(function WorktreeList({ }), [projectHostSetupProjection] ) - const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS) - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) - const effectiveCollapsedGroups = useMemo(() => { - if (!agentSendTargetWorktreeId) { - return collapsedGroups - } - const targetWorktree = worktreeMap.get(agentSendTargetWorktreeId) - if (!targetWorktree) { - return collapsedGroups - } - const next = new Set(collapsedGroups) - if (targetWorktree.isPinned) { - next.delete(PINNED_GROUP_KEY) - } else { - for (const groupKey of getGroupKeysForWorktree( - groupBy, - targetWorktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups, - projectGrouping - )) { - next.delete(groupKey) - } - } - for (const parent of getWorktreeLineageAncestors( - targetWorktree, - worktreeLineageById, - worktreeMap - )) { - next.delete(getLineageGroupKey(parent.id)) - } - return next - }, [ - agentSendTargetWorktreeId, - collapsedGroups, - groupBy, - prCache, - projectGroups, - projectGrouping, + const agentSendTargetWorktreeId = useAgentSendTargetWorktreeId() + const { filterState, hasFilters, clearFilters } = useSidebarWorktreeFilters() + const sortedIds = useSidebarWorktreeSortOrder({ allWorktrees, repoMap, sortBy }) + const { visibleWorktrees, pairedDeviceIdsByEnvironment } = useVisibleSidebarWorktrees({ + filterState, + sortBy, + sortedIds, repoMap, + worktreeMap, + worktreeLineageById, + settings, + agentSendTargetWorktreeId + }) + const effectiveCollapsedGroups = useEffectiveCollapsedGroups({ + collapsedGroups, + agentSendTargetWorktreeId, + groupBy, + repoMap, + worktreeMap, + worktreeLineageById, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + }) + const visibleScope = useSidebarHostVisibleScope({ + filterState, + defaultHostId, + repos, + projectGroups, + folderWorkspaces, + pairedDeviceIdsByEnvironment + }) + const externalWorktreeCards = useSidebarExternalWorktreeCards({ + repos, + visibleReposForRows: visibleScope.visibleReposForRows, + detectedWorktreesByRepo, + filterRepoIds: filterState.filterRepoIds + }) + const rowModel = useSidebarSectionRows({ + groupBy, + projectOrderBy, + pinnedDisplayPolicy, + defaultHostId, + worktrees: visibleWorktrees, + repos, + repoMap, + worktreeMap, + worktreeLineageById, + prCache, settings, workspaceStatuses, - worktreeLineageById, - worktreeMap - ]) - const defaultHostId = getSettingsFocusedExecutionHostId(settings) - const visibleHostIdSet = useMemo( - () => getVisibleSidebarHostIdSet(visibleWorkspaceHostIds, workspaceHostScope), - [visibleWorkspaceHostIds, workspaceHostScope] - ) - const visibleReposForRows = useMemo(() => { - if (!visibleHostIdSet) { - return repos - } - return repos.filter((repo) => { - const hostId = - repo.connectionId || repo.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId - return visibleHostIdSet.has(hostId) - }) - }, [defaultHostId, repos, visibleHostIdSet]) - const visibleProjectGroupsForRows = useMemo( - () => filterProjectGroupsForVisibleHosts(projectGroups, visibleHostIdSet, defaultHostId), - [defaultHostId, projectGroups, visibleHostIdSet] - ) - const visibleFolderWorkspacesForRows = useMemo(() => { - const hostVisibleWorkspaces = filterFolderWorkspacesForVisibleHosts( - folderWorkspaces, - projectGroups, - visibleHostIdSet, - defaultHostId - ) - if (!hideWorkspacesFromOtherDevices) { - return hostVisibleWorkspaces - } - return filterFolderWorkspacesFromOtherDevices( - hostVisibleWorkspaces, - pairedDeviceIdsByEnvironment - ) - }, [ - defaultHostId, - folderWorkspaces, - hideWorkspacesFromOtherDevices, - pairedDeviceIdsByEnvironment, - projectGroups, - visibleHostIdSet - ]) - const repoOrder = useMemo(() => { - return getLogicalRepoOrderRankById(repos.map((repo) => repo.id)) - }, [repos]) - const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState< - Map - >(new Map()) - const [newExternalWorktreeInboxActionState, setNewExternalWorktreeInboxActionState] = useState< - Map - >(new Map()) - const [suppressExternalWorktreeInboxRepoId, setSuppressExternalWorktreeInboxRepoId] = useState< - string | null - >(null) - const importedWorktreesByRepo = useMemo(() => { - const forceVisibleRepoIds = new Set( - [...importedWorktreeCardActionState.entries()] - .filter(([, state]) => state.forceVisible) - .map(([repoId]) => repoId) - ) - return buildImportedWorktreesCardCandidates({ - repos: visibleReposForRows, - detectedWorktreesByRepo, - filterRepoIds, - forceVisibleRepoIds - }) - }, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, visibleReposForRows]) - const newExternalWorktreesInboxByRepo = useMemo( - () => - buildNewExternalWorktreesInboxCandidates({ - repos: visibleReposForRows, - detectedWorktreesByRepo, - filterRepoIds - }), - [detectedWorktreesByRepo, filterRepoIds, visibleReposForRows] - ) - const placeholderRepoIds = useMemo(() => { - return getEmptyProjectPlaceholderRepoIds({ - groupBy, - repos: visibleReposForRows, - worktreesByRepo, - visibleWorktrees, - filterRepoIds - }) - }, [filterRepoIds, groupBy, visibleReposForRows, visibleWorktrees, worktreesByRepo]) - const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) - - // Why: subscribe on a flat key array (useShallow) so progress ticks don't rebuild the whole row model. - // Split on first space — creationId is a UUID (no space) so a space-containing repoId stays intact. - const pendingCreationKeys = useAppStore( - useShallow((s) => - Object.values(s.pendingWorktreeCreations ?? {}).map( - (creation) => `${creation.creationId} ${creation.request.repoId}` - ) - ) - ) - const pendingCreations = useMemo( - () => - pendingCreationKeys.map((key) => { - const separator = key.indexOf(' ') - return { creationId: key.slice(0, separator), repoId: key.slice(separator + 1) } - }), - [pendingCreationKeys] - ) - const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) - const hostOptions = useMemo( - () => - buildSidebarHostOptions({ - repos, - sshTargetLabels, - sshConnectionStates, - settings, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - hostLabelOverrides - }), - [ - repos, - sshTargetLabels, - sshConnectionStates, - settings, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - hostLabelOverrides - ] - ) - const hostLabelById = useMemo( - () => new Map(hostOptions.map((host) => [host.id, host.label])), - [hostOptions] - ) - - const rows: Row[] = useMemo( - () => - buildRows( - groupBy, - worktrees, - repoMap, - prCache, - effectiveCollapsedGroups, - repoOrder, - workspaceStatuses, - projectOrderBy, - worktreeLineageById, - worktreeMap, - true, - settings, - visibleProjectGroupsForRows, - placeholderRepoIds, - importedWorktreesByRepo, - newExternalWorktreesInboxByRepo, - pendingCreations, - projectGrouping, - visibleFolderWorkspacesForRows, - hostLabelById, - defaultHostId, - pinnedDisplayPolicy - ), - [ - groupBy, - worktrees, - repoMap, - prCache, - effectiveCollapsedGroups, - defaultHostId, - repoOrder, - workspaceStatuses, - projectOrderBy, - worktreeLineageById, - worktreeMap, - settings, - projectGrouping, - visibleProjectGroupsForRows, - visibleFolderWorkspacesForRows, - placeholderRepoIds, - importedWorktreesByRepo, - newExternalWorktreesInboxByRepo, - pendingCreations, - hostLabelById, - pinnedDisplayPolicy - ] - ) - const orderedHostOptions = useMemo( - () => orderHostSectionOptions(hostOptions, workspaceHostOrder), - [hostOptions, workspaceHostOrder] - ) - const [hostDragActive, setHostDragActive] = useState(false) - const handleReorderHostSections = useCallback( - (orderedVisibleHostIds: ExecutionHostId[]) => { - const visibleHostIds = new Set(orderedVisibleHostIds) - const hostOptionIds = orderedHostOptions.map((host) => host.id) - const knownHostIds = new Set(hostOptionIds) - const nextOrder: ExecutionHostId[] = [...orderedVisibleHostIds] - const seen = new Set(nextOrder) - // Why: dragging only covers rendered hosts; keep non-rendered SSH/runtime hosts in the saved order so they return in place. - for (const hostId of [...workspaceHostOrder, ...hostOptionIds]) { - if (!knownHostIds.has(hostId) || visibleHostIds.has(hostId) || seen.has(hostId)) { - continue - } - nextOrder.push(hostId) - seen.add(hostId) - } - setWorkspaceHostOrder(nextOrder) - }, - [orderedHostOptions, setWorkspaceHostOrder, workspaceHostOrder] - ) - const sectionRows = useMemo( - () => - addHostSectionRows({ - rows, - hostOptions: orderedHostOptions, - workspaceHostScope, - visibleWorkspaceHostIds, - defaultHostId, - collapsedHostKeys: effectiveCollapsedGroups, - forceCollapseHosts: hostDragActive, - // Why: projects/workspaces are the primary sidebar object; host sections are only an explicit host-filter view. - preferProjectGrouping: true - }), - [ - defaultHostId, - effectiveCollapsedGroups, - hostDragActive, - orderedHostOptions, - rows, - visibleWorkspaceHostIds, - workspaceHostScope - ] - ) - const renderedSidebarRowKeys = useMemo(() => { - const keys = new Set() - for (const row of sectionRows) { - if (row.type === 'header') { - keys.add(row.key) - } else if (row.type === 'item') { - keys.add(row.rowKey) - } else if (row.type === 'folder-workspace') { - keys.add(folderWorkspaceKey(row.folderWorkspace.id)) - } else if (row.type === 'pending-creation') { - keys.add(`pending:${row.creationId}`) - } else if (row.type === 'imported-worktrees-card') { - keys.add(row.key) - } else if (row.type === 'new-external-worktrees-inbox') { - keys.add(row.key) - } - } - return keys - }, [sectionRows]) - // Why: status headers move during wake (inactive -> active); key only on grouping mode so row identity survives. - const visibleHostResetKey = visibleWorkspaceHostIds?.join(',') ?? 'all' - const viewportResetKey = `group:${groupBy}:host:${visibleHostResetKey}:lineage` - - // Why: derive order from the built rows, not the flat worktrees array, so Cmd+1–9 match visual positions when grouping reorders cards. - const renderedWorktrees = useMemo( - () => getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy), - [pinnedDisplayPolicy, sectionRows] - ) - // Why: order-preserving sectionRows rebuilds must not give this array a new - // identity — updateSelectionForGesture depends on it, and a fresh identity - // there defeats React.memo bail-out for every WorktreeCard on epoch bumps. - const renderedWorktreeIds = useReusedArrayIdentity( - useMemo( - () => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)), - [renderedWorktrees] - ) - ) - const [selectedWorktreeIds, setSelectedWorktreeIds] = useState>(new Set()) - const [selectionAnchorId, setSelectionAnchorId] = useState(null) - - const prunedSelection = pruneWorktreeSelection( - selectedWorktreeIds, - selectionAnchorId, - renderedWorktreeIds - ) - // Why: filters/grouping can hide selected cards; prune during render so nothing sees stale ids for unrendered worktrees. - if (!areWorktreeSelectionsEqual(selectedWorktreeIds, prunedSelection.selectedIds)) { - setSelectedWorktreeIds(prunedSelection.selectedIds) - } - if (selectionAnchorId !== prunedSelection.anchorId) { - setSelectionAnchorId(prunedSelection.anchorId) - } - - // Why identity reuse: the empty/unchanged-selection case must keep one array - // identity — selectForContextMenu and both drag-start handlers depend on - // this array, and card memo bail-out depends on those staying stable. - const selectedWorktrees = useReusedArrayIdentity( - useMemo(() => { - if (selectedWorktreeIds.size === 0) { - return [] - } - const selected = new Map() - for (const worktree of renderedWorktrees) { - if (selectedWorktreeIds.has(worktree.id) && !selected.has(worktree.id)) { - selected.set(worktree.id, worktree) - } - } - return Array.from(selected.values()) - }, [renderedWorktrees, selectedWorktreeIds]) - ) - - useEffect(() => { - if (selectedWorktreeIds.size === 0) { - return - } - - const clearSelectionOutsideSidebar = (event: PointerEvent): void => { - const target = event.target - const sidebarContainer = document.querySelector('[data-worktree-sidebar-container]') - if (target instanceof Node && sidebarContainer?.contains(target)) { - return - } - setSelectedWorktreeIds(new Set()) - setSelectionAnchorId(null) - } - - document.addEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) - return () => { - document.removeEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) - } - }, [selectedWorktreeIds.size]) - - const updateSelectionForGesture = useCallback( - (event: React.MouseEvent, worktreeId: string): boolean => { - const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac')) - const result = updateWorktreeSelection({ - visibleIds: renderedWorktreeIds, - previousSelectedIds: selectedWorktreeIds, - previousAnchorId: selectionAnchorId, - targetId: worktreeId, - intent - }) - setSelectedWorktreeIds(result.selectedIds) - setSelectionAnchorId(result.anchorId) - // Plain click navigates; modifier gestures are selection-only so a batch can build without switching away. - return intent !== 'replace' - }, - [renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId] - ) - - const selectForContextMenu = useCallback( - (_event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => { - if (selectedWorktreeIds.has(worktree.id) && selectedWorktreeIds.size > 1) { - return selectedWorktrees - } - setSelectedWorktreeIds(new Set([worktree.id])) - setSelectionAnchorId(worktree.id) - return [worktree] - }, - [selectedWorktreeIds, selectedWorktrees] - ) + effectiveCollapsedGroups, + projectGrouping, + visibleReposForRows: visibleScope.visibleReposForRows, + visibleProjectGroupsForRows: visibleScope.visibleProjectGroupsForRows, + visibleFolderWorkspacesForRows: visibleScope.visibleFolderWorkspacesForRows, + importedWorktreesByRepo: externalWorktreeCards.importedWorktreesByRepo, + newExternalWorktreesInboxByRepo: externalWorktreeCards.newExternalWorktreesInboxByRepo, + filterRepoIds: filterState.filterRepoIds, + visibleWorkspaceHostIds: filterState.visibleWorkspaceHostIds, + workspaceHostScope: filterState.workspaceHostScope + }) + const selection = useSidebarWorktreeSelection({ + sectionRows: rowModel.sectionRows, + pinnedDisplayPolicy + }) + const statusMutations = useWorktreeStatusMutations({ worktreeMap, workspaceStatuses, sortBy }) + const projectGroupDialogs = useProjectGroupDialogs({ repos, repoMap, projectGroups }) const handleImmediateWorktreeActivate = useCallback((worktreeId: string, rowKey?: string) => { // Why: re-rendering the virtualized sidebar on the pointer path adds visible latency; mutate the row directly and let store state reconcile after. markSidebarWorktreeActiveImmediately(worktreeId, rowKey) }, []) - // Why: full-page nav views aren't scoped to a worktree, so no sidebar card should look selected. - const selectedSidebarWorktreeId = - activeView === 'tasks' || activeView === 'activity' ? null : currentSidebarWorktreeId - - // Why layout effect: the Cmd/Ctrl+1–9 handler can fire right after commit; publishing after paint would leave the shortcut cache stale. - useLayoutEffect(() => { - setVisibleWorktreeIds(renderedWorktreeIds) - // Why null, not []: [] is a real rendered order (all collapsed/filtered); null tells shortcuts the list is unmounted. - return () => setVisibleWorktreeIds(null) - }, [renderedWorktreeIds]) - const handleCreateForRepo = useCallback( (projectId: string) => { openModal('new-workspace-composer', { initialRepoId: projectId, telemetrySource: 'sidebar' }) }, [openModal] ) - const handleOpenRepoSettings = useCallback( (projectId: string, sectionId?: string) => { openSettingsTarget({ pane: 'repo', repoId: projectId, ...(sectionId ? { sectionId } : {}) }) @@ -6036,141 +191,12 @@ const WorktreeList = React.memo(function WorktreeList({ }, [openSettingsPage, openSettingsTarget] ) - const handleOpenWorktreeVisibility = useCallback( (repo: Repo) => { - openModal('worktree-visibility', { - repoId: repo.id, - hostId: getRepoExecutionHostId(repo) - }) + openModal('worktree-visibility', { repoId: repo.id, hostId: getRepoExecutionHostId(repo) }) }, [openModal] ) - - const setImportedWorktreeCardState = useCallback( - (projectId: string, state: ImportedWorktreeCardActionState | null) => { - setImportedWorktreeCardActionState((previous) => { - const next = new Map(previous) - if (state) { - next.set(projectId, state) - } else { - next.delete(projectId) - } - return next - }) - }, - [] - ) - - const handleShowImportedWorktrees = useCallback( - async (projectId: string) => { - await showImportedWorktreesCard({ - projectId, - forceVisible: importedWorktreeCardActionState.get(projectId)?.forceVisible === true, - updateRepo, - fetchWorktrees, - setCardState: setImportedWorktreeCardState - }) - }, - [fetchWorktrees, importedWorktreeCardActionState, setImportedWorktreeCardState, updateRepo] - ) - - const handleKeepImportedWorktreesHidden = useCallback( - async (projectId: string) => { - const repo = repos.find((candidate) => candidate.id === projectId) - let detected = detectedWorktreesByRepo[projectId] - // Why: baseline seeding needs authoritative hidden paths, so don't dismiss on a stale snapshot. - if (detected?.authoritative !== true) { - const refreshed = await fetchWorktrees(projectId, { requireAuthoritative: true }) - if (!refreshed) { - setImportedWorktreeCardState(projectId, { - pending: false, - error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR - }) - return - } - detected = useAppStore.getState().detectedWorktreesByRepo[projectId] - } - if (detected?.authoritative !== true) { - setImportedWorktreeCardState(projectId, { - pending: false, - error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR - }) - return - } - const hiddenWorktrees = getHiddenImportedWorktrees(detected) - await keepImportedWorktreesHiddenCard({ - projectId, - updateRepo, - setCardState: setImportedWorktreeCardState, - hiddenWorktreePaths: hiddenWorktrees.map((worktree) => worktree.path), - existingBaselinePaths: repo?.externalWorktreeInboxBaselinePaths - }) - }, - [detectedWorktreesByRepo, fetchWorktrees, repos, setImportedWorktreeCardState, updateRepo] - ) - - const setNewExternalWorktreeInboxState = useCallback( - (projectId: string, state: NewExternalWorktreesInboxActionState | null) => { - setNewExternalWorktreeInboxActionState((previous) => { - const next = new Map(previous) - if (state) { - next.set(projectId, state) - } else { - next.delete(projectId) - } - return next - }) - }, - [] - ) - - const getNewExternalWorktreeInboxActionArgs = useCallback( - (projectId: string, worktreePaths: readonly string[]) => { - const repo = repos.find((candidate) => candidate.id === projectId) - if (!repo) { - return null - } - return { - projectId, - repo, - worktreePaths, - updateRepo, - fetchWorktrees, - setInboxState: setNewExternalWorktreeInboxState - } - }, - [fetchWorktrees, repos, setNewExternalWorktreeInboxState, updateRepo] - ) - - const handleOpenSuppressExternalWorktreeInbox = useCallback((projectId: string) => { - setSuppressExternalWorktreeInboxRepoId(projectId) - }, []) - - const handleConfirmSuppressExternalWorktreeInbox = useCallback(async () => { - if (!suppressExternalWorktreeInboxRepoId) { - return - } - const projectId = suppressExternalWorktreeInboxRepoId - const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? [] - const args = getNewExternalWorktreeInboxActionArgs( - projectId, - inboxWorktrees.map((worktree) => worktree.path) - ) - if (!args) { - setSuppressExternalWorktreeInboxRepoId(null) - return - } - const suppressed = await suppressNewExternalWorktreeInbox(args) - if (suppressed) { - setSuppressExternalWorktreeInboxRepoId(null) - } - }, [ - getNewExternalWorktreeInboxActionArgs, - newExternalWorktreesInboxByRepo, - suppressExternalWorktreeInboxRepoId - ]) - const handleRemoveProject = useCallback( (repo: Repo) => { openModal('confirm-remove-folder', { @@ -6181,139 +207,6 @@ const WorktreeList = React.memo(function WorktreeList({ }, [openModal] ) - - const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) - const createProjectGroup = useAppStore((s) => s.createProjectGroup) - const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) - const deleteProjectGroupWithContainedProjects = useAppStore( - (s) => s.deleteProjectGroupWithContainedProjects - ) - const [projectGroupNameDialog, setProjectGroupNameDialog] = - useState(null) - const [projectGroupDeleteDialog, setProjectGroupDeleteDialog] = - useState(null) - - const handleCreateGroupFromRepo = useCallback((repo: Repo) => { - setProjectGroupNameDialog({ type: 'create-from-repo', repo }) - }, []) - - const handleMoveProjectToGroup = useCallback( - (repo: Repo, groupId: string) => { - if (repo.projectGroupId === groupId) { - return - } - void moveProjectToGroup(repo.id, groupId) - }, - [moveProjectToGroup] - ) - - const handleRemoveProjectFromGroup = useCallback( - (repo: Repo) => { - void moveProjectToGroup(repo.id, null) - }, - [moveProjectToGroup] - ) - - const handleRenameProjectGroup = useCallback((groupId: string, currentName: string) => { - setProjectGroupNameDialog({ type: 'rename', groupId, currentName }) - }, []) - - const handleSubmitProjectGroupName = useCallback( - async (name: string) => { - if (!projectGroupNameDialog) { - return - } - if (projectGroupNameDialog.type === 'create-from-repo') { - const group = await createProjectGroup(name) - if (group) { - await moveProjectToGroup(projectGroupNameDialog.repo.id, group.id) - } - return - } - await updateProjectGroup(projectGroupNameDialog.groupId, { name }) - }, - [createProjectGroup, moveProjectToGroup, projectGroupNameDialog, updateProjectGroup] - ) - - const projectGroupDeleteTargets = useMemo(() => { - if (!projectGroupDeleteDialog) { - return null - } - return selectProjectGroupRemovalTargets(projectGroups, repos, projectGroupDeleteDialog.groupId) - }, [projectGroupDeleteDialog, projectGroups, repos]) - const projectGroupDeleteProjectCount = projectGroupDeleteTargets?.projectIds.length ?? 0 - const projectGroupDeleteProjectNames = useMemo( - () => - (projectGroupDeleteTargets?.projectIds ?? []).map( - (projectId) => repoMap.get(projectId)?.displayName ?? projectId - ), - [projectGroupDeleteTargets, repoMap] - ) - const projectGroupRemoveContainedProjects = - projectGroupDeleteProjectCount > 0 && projectGroupDeleteDialog?.removeContainedProjects === true - - const handleDeleteProjectGroup = useCallback((groupId: string, groupName: string) => { - setProjectGroupDeleteDialog({ groupId, groupName, removeContainedProjects: false }) - }, []) - - const handleConfirmDeleteProjectGroup = useCallback(async () => { - if (!projectGroupDeleteDialog) { - return - } - try { - const result = await deleteProjectGroupWithContainedProjects( - projectGroupDeleteDialog.groupId, - { - removeContainedProjects: projectGroupRemoveContainedProjects - } - ) - // Why: a missing group is already the desired end state, so only a real delete failure warrants a toast. - if (result.status === 'group-delete-failed') { - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.groupDeleteFailed', - 'Failed to delete group' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeList.groupDeleteFailedDesc', - 'Something went wrong while deleting the group. No projects were removed.' - ) - } - ) - return - } - if (result.status === 'deleted-group' && result.failedProjectRemovals.length > 0) { - const failedCount = result.failedProjectRemovals.length - const requestedCount = result.requestedProjectIds.length - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.b667b59632', - 'Some projects could not be removed from Orca' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeList.f94466bc39', - '{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.', - { - value0: failedCount, - value1: requestedCount, - value2: requestedCount === 1 ? '' : 's' - } - ) - } - ) - } - } finally { - // Why: deleting contained projects can unmount this dialog before its close handler runs, so the parent owns cleanup. - setProjectGroupDeleteDialog(null) - } - }, [ - deleteProjectGroupWithContainedProjects, - projectGroupRemoveContainedProjects, - projectGroupDeleteDialog - ]) - const handleCreateFolderWorkspace = useCallback( (projectGroup: ProjectGroup) => { if (!projectGroup.parentPath) { @@ -6327,472 +220,55 @@ const WorktreeList = React.memo(function WorktreeList({ [openModal] ) - const moveWorktreeToStatus = useCallback( - (worktreeId: string, status: WorkspaceStatus) => { - const current = worktreeMap.get(worktreeId) - if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { - return - } - void updateWorktreeMeta(worktreeId, { workspaceStatus: status }) - }, - [updateWorktreeMeta, worktreeMap, workspaceStatuses] - ) - - const moveWorktreesToStatus = useCallback( - (worktreeIds: readonly string[], status: WorkspaceStatus) => { - const updates = new Map() - for (const worktreeId of worktreeIds) { - const current = worktreeMap.get(worktreeId) - if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { - continue - } - updates.set(worktreeId, { workspaceStatus: status }) - } - if (updates.size > 0) { - void updateWorktreesMeta(updates) - } - }, - [updateWorktreesMeta, worktreeMap, workspaceStatuses] - ) - - const moveWorktreesToStatusAtIndex = useCallback( - (args: { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] - }) => { - const targetGroupKey = getWorkspaceStatusGroupKey(args.status) - const rankByWorktreeId = new Map() - for (const group of args.groups) { - for (const worktreeId of group.worktreeIds) { - const worktree = worktreeMap.get(worktreeId) - if (worktree) { - rankByWorktreeId.set(worktreeId, worktree.manualOrder ?? worktree.sortOrder) - } - } - } - const order = buildManualOrderUpdatesForGroupDrop({ - groups: args.groups, - targetGroupKey, - draggedIds: args.worktreeIds, - dropIndex: args.dropIndex, - now: Date.now(), - rankByWorktreeId - }) - const updates = new Map>() - for (const worktreeId of args.worktreeIds) { - const current = worktreeMap.get(worktreeId) - if (!current) { - continue - } - const next: Partial = {} - if (getWorkspaceStatus(current, workspaceStatuses) !== args.status) { - next.workspaceStatus = args.status - } - updates.set(worktreeId, next) - } - for (const [worktreeId, manualOrder] of order.updates) { - updates.set(worktreeId, { ...updates.get(worktreeId), ...manualOrder }) - } - for (const [worktreeId, update] of Array.from(updates)) { - if (Object.keys(update).length === 0) { - updates.delete(worktreeId) - } - } - if (updates.size === 0) { - return - } - // Why: the insertion line promises exact placement, so persist manual order on a cross-status drop. - if (order.changed) { - setSortBy('manual') - } - void updateWorktreesMeta(updates) - }, - [setSortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] - ) - - const pinWorktree = useCallback( - (worktreeId: string) => { - setWorktreesPinnedAndReveal([worktreeId], true) - }, - [setWorktreesPinnedAndReveal] - ) - - const pinWorktrees = useCallback( - (worktreeIds: readonly string[]) => { - setWorktreesPinnedAndReveal(worktreeIds, true) - }, - [setWorktreesPinnedAndReveal] - ) - - const reorderWorktrees = useCallback( - (args: { - groups: readonly WorktreeDragGroup[] - sourceGroupKey: string - draggedIds: readonly string[] - dropIndex: number - }) => { - const rankByWorktreeId = new Map() - for (const group of args.groups) { - for (const worktreeId of group.worktreeIds) { - const worktree = worktreeMap.get(worktreeId) - if (worktree) { - rankByWorktreeId.set(worktreeId, worktree.manualOrder ?? worktree.sortOrder) - } - } - } - const result = buildManualOrderUpdatesForVisibleGroups({ - ...args, - now: Date.now(), - rankByWorktreeId - }) - if (!result.changed) { - return - } - // Why: only switch to Manual after a real move so accidental click-drags don't change the sort. - setSortBy('manual') - void updateWorktreesMeta(result.updates) - }, - [setSortBy, updateWorktreesMeta, worktreeMap] - ) - - const shouldShowWorkspaceBoardDropIndicator = useCallback( - (worktreeIds: readonly string[], status: WorkspaceStatus) => { - const sourceGroupKeys = worktreeIds.flatMap((worktreeId) => { - const worktree = worktreeMap.get(worktreeId) - return worktree ? [getWorkspaceStatus(worktree, workspaceStatuses)] : [] - }) - return shouldWriteManualOrderForGroupDrop({ - sortBy, - sourceGroupKeys, - targetGroupKey: status - }) - }, - [sortBy, worktreeMap, workspaceStatuses] - ) - - const dropWorktreesOnWorkspaceBoard = useCallback( - (args: { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] - }) => { - const result = buildWorkspaceKanbanSidebarDropUpdates({ - ...args, - worktreeById: worktreeMap, - workspaceStatuses, - sortBy, - now: Date.now() - }) - if (result.updates.size === 0) { - return - } - // Why: switch to Manual when the drop changes order so the placement stays visible. - if (result.shouldSwitchToManual) { - setSortBy('manual') - } - useAppStore.getState().recordFeatureInteraction('workspace-board-actions') - void updateWorktreesMeta(result.updates) - }, - [setSortBy, sortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] - ) - - // Why: count hideDefaultBranchWorkspace as a filter so the Clear Filters escape hatch stays reachable when it alone empties the list. - const filterState = useMemo( - () => ({ - showSleepingWorkspaces, - filterRepoIds, - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - alwaysShowDefaultBranchWorkspace, - visibleWorkspaceHostIds, - workspaceHostScope - }), - [ - showSleepingWorkspaces, - filterRepoIds, - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - alwaysShowDefaultBranchWorkspace, - visibleWorkspaceHostIds, - workspaceHostScope - ] - ) - const hasFilters = sidebarHasActiveFilters(filterState) - const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) - const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) - const setHideAutomationGeneratedWorkspaces = useAppStore( - (s) => s.setHideAutomationGeneratedWorkspaces - ) - const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) - const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) - const setHideWorkspacesFromOtherDevices = useAppStore((s) => s.setHideWorkspacesFromOtherDevices) - const setAlwaysShowDefaultBranchWorkspace = useAppStore( - (s) => s.setAlwaysShowDefaultBranchWorkspace - ) - const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) - const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) - - const clearFilters = useCallback(() => { - const actions = computeClearFilterActions(filterState) - if (actions.resetShowSleepingWorkspaces) { - setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES) - } - if (actions.resetFilterRepoIds) { - setFilterRepoIds([]) - } - if (actions.resetHideDefaultBranchWorkspace) { - setHideDefaultBranchWorkspace(false) - } - if (actions.resetHideAutomationGeneratedWorkspaces) { - setHideAutomationGeneratedWorkspaces(false) - } - if (actions.resetHideCliCreatedWorkspaces) { - setHideCliCreatedWorkspaces(false) - } - if (actions.resetHideDetachedHeadWorkspaces) { - setHideDetachedHeadWorkspaces(false) - } - if (actions.resetHideWorkspacesFromOtherDevices) { - setHideWorkspacesFromOtherDevices(false) - } - if (actions.resetAlwaysShowDefaultBranchWorkspace) { - setAlwaysShowDefaultBranchWorkspace(true) - } - if (actions.resetVisibleWorkspaceHostIds) { - setVisibleWorkspaceHostIds(null) - } - }, [ - setShowSleepingWorkspaces, - setFilterRepoIds, - setHideDefaultBranchWorkspace, - setHideAutomationGeneratedWorkspaces, - setHideCliCreatedWorkspaces, - setHideDetachedHeadWorkspaces, - setHideWorkspacesFromOtherDevices, - setAlwaysShowDefaultBranchWorkspace, - setVisibleWorkspaceHostIds, - filterState - ]) - - useEffect(() => { - if (!pendingRevealSidebarRow) { - return - } - const rowKey = pendingRevealSidebarRow.rowKey - const isProjectHeaderTarget = - rowKey.startsWith('project-group:') || - rowKey.startsWith('project:') || - rowKey.startsWith('repo:') - if (isProjectHeaderTarget && groupBy !== 'repo') { - setGroupBy('repo') - return - } - if (!renderedSidebarRowKeys.has(rowKey) && hasFilters) { - clearFilters() - } - }, [ - clearFilters, + useSidebarRevealRequests({ groupBy, + renderedSidebarRowKeys: rowModel.renderedSidebarRowKeys, + renderedWorktreeIds: selection.renderedWorktreeIds, + currentSidebarWorktreeId, + worktreeMap, + folderWorkspaces, hasFilters, - pendingRevealSidebarRow, - renderedSidebarRowKeys, - setGroupBy - ]) - - const handleRevealCurrentWorkspaceRequest = useCallback( - (event: Event) => { - const detail = - event instanceof CustomEvent - ? (event.detail as ScrollToCurrentWorkspaceRevealRequestDetail | undefined) - : undefined - if (detail?.target?.type === 'sidebar-row') { - const sidebarDetail = detail as Extract< - ScrollToCurrentWorkspaceRevealRequestDetail, - { target: { type: 'sidebar-row' } } - > - revealSidebarRow(detail.target.rowKey, { - behavior: 'smooth', - highlight: sidebarDetail.highlight !== false - }) - return - } - if (!currentSidebarWorktreeId) { - return - } - const activeWorktree = getKnownSidebarWorktreeById( - currentSidebarWorktreeId, - worktreeMap, - folderWorkspaces - ) - if (!activeWorktree || activeWorktree.isArchived) { - return - } - if (!renderedWorktreeIds.includes(currentSidebarWorktreeId)) { - // Why: the reveal action must show the current workspace, so relax filters that hide it first. - clearFilters() - } - revealWorktreeInSidebar(currentSidebarWorktreeId, { - behavior: 'smooth', - highlight: true, - beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true - }) - }, - [ - clearFilters, - currentSidebarWorktreeId, - folderWorkspaces, - revealSidebarRow, - renderedWorktreeIds, - revealWorktreeInSidebar, - worktreeMap - ] - ) - - useEffect(() => { - window.addEventListener( - SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, - handleRevealCurrentWorkspaceRequest - ) - return () => { - window.removeEventListener( - SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, - handleRevealCurrentWorkspaceRequest - ) - } - }, [handleRevealCurrentWorkspaceRequest]) + clearFilters + }) const filtersHideAllRows = hasFilters && - worktrees.length === 0 && - placeholderRepoIds.size === 0 && - importedWorktreesByRepo.size === 0 + visibleWorktrees.length === 0 && + rowModel.placeholderRepoIds.size === 0 && + externalWorktreeCards.importedWorktreesByRepo.size === 0 // Why: when active filters hide every row, the Clear Filters empty state must win over Project Group headers. - if (rows.length === 0 || filtersHideAllRows) { - return ( -
-
-
- - {translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')} - - {hasFilters && ( - - )} -
-
-
- ) + if (rowModel.rows.length === 0 || filtersHideAllRows) { + return } return ( <> - { - if (!open) { - setProjectGroupNameDialog(null) - } + onConfirmSuppressExternalWorktreeInbox={() => { + void externalWorktreeCards.handleConfirmSuppressExternalWorktreeInbox() }} - onSubmit={handleSubmitProjectGroupName} - /> - repo.id === suppressExternalWorktreeInboxRepoId)?.displayName ?? - '') - : '' - } - pending={ - suppressExternalWorktreeInboxRepoId - ? (newExternalWorktreeInboxActionState.get(suppressExternalWorktreeInboxRepoId) - ?.pending ?? false) - : false - } - onOpenChange={(open) => { - if (!open) { - setSuppressExternalWorktreeInboxRepoId(null) - } - }} - onConfirm={() => { - void handleConfirmSuppressExternalWorktreeInbox() - }} - onOpenRecovery={() => { - if (!suppressExternalWorktreeInboxRepoId) { - return - } - const repo = findRepoForHost(repos, suppressExternalWorktreeInboxRepoId, { settings }) - setSuppressExternalWorktreeInboxRepoId(null) - if (repo) { - handleOpenWorktreeVisibility(repo) - } - }} - /> - { - setProjectGroupDeleteDialog((current) => - current ? { ...current, removeContainedProjects } : current - ) - }} - onOpenChange={(open) => { - if (!open) { - setProjectGroupDeleteDialog(null) - } - }} - onConfirm={handleConfirmDeleteProjectGroup} + onOpenWorktreeVisibility={handleOpenWorktreeVisibility} /> active); key only on grouping mode so row identity survives. + key={`group:${groupBy}:host:${filterState.visibleWorkspaceHostIds?.join(',') ?? 'all'}:lineage`} + rows={rowModel.sectionRows} + // Why: full-page nav views aren't scoped to a worktree, so no sidebar card should look selected. + activeWorktreeId={ + activeView === 'tasks' || activeView === 'activity' ? null : currentSidebarWorktreeId + } currentWorktreeId={currentSidebarWorktreeId} groupBy={groupBy} pinnedDisplayPolicy={pinnedDisplayPolicy} @@ -6802,17 +278,21 @@ const WorktreeList = React.memo(function WorktreeList({ handleCreateForRepo={handleCreateForRepo} handleOpenRepoSettings={handleOpenRepoSettings} handleOpenWorktreeVisibility={handleOpenWorktreeVisibility} - handleShowImportedWorktrees={handleShowImportedWorktrees} - handleKeepImportedWorktreesHidden={handleKeepImportedWorktreesHidden} - importedWorktreeCardActionState={importedWorktreeCardActionState} - handleOpenSuppressExternalWorktreeInbox={handleOpenSuppressExternalWorktreeInbox} - newExternalWorktreeInboxActionState={newExternalWorktreeInboxActionState} + handleShowImportedWorktrees={externalWorktreeCards.handleShowImportedWorktrees} + handleKeepImportedWorktreesHidden={externalWorktreeCards.handleKeepImportedWorktreesHidden} + importedWorktreeCardActionState={externalWorktreeCards.importedWorktreeCardActionState} + handleOpenSuppressExternalWorktreeInbox={ + externalWorktreeCards.handleOpenSuppressExternalWorktreeInbox + } + newExternalWorktreeInboxActionState={ + externalWorktreeCards.newExternalWorktreeInboxActionState + } handleRemoveProject={handleRemoveProject} - handleCreateGroupFromRepo={handleCreateGroupFromRepo} - handleMoveProjectToGroup={handleMoveProjectToGroup} - handleRemoveProjectFromGroup={handleRemoveProjectFromGroup} - handleRenameProjectGroup={handleRenameProjectGroup} - handleDeleteProjectGroup={handleDeleteProjectGroup} + handleCreateGroupFromRepo={projectGroupDialogs.handleCreateGroupFromRepo} + handleMoveProjectToGroup={projectGroupDialogs.handleMoveProjectToGroup} + handleRemoveProjectFromGroup={projectGroupDialogs.handleRemoveProjectFromGroup} + handleRenameProjectGroup={projectGroupDialogs.handleRenameProjectGroup} + handleDeleteProjectGroup={projectGroupDialogs.handleDeleteProjectGroup} handleCreateFolderWorkspace={handleCreateFolderWorkspace} activeModal={activeModal} pendingRevealWorktree={pendingRevealWorktree} @@ -6820,38 +300,40 @@ const WorktreeList = React.memo(function WorktreeList({ clearPendingRevealWorktreeId={clearPendingRevealWorktreeId} clearPendingRevealSidebarRow={clearPendingRevealSidebarRow} agentSendTargetWorktreeId={agentSendTargetWorktreeId} - worktrees={worktrees} + worktrees={visibleWorktrees} folderWorkspaces={folderWorkspaces} - selectedWorktreeIds={selectedWorktreeIds} - selectedWorktrees={selectedWorktrees} - onSelectionGesture={updateSelectionForGesture} + selectedWorktreeIds={selection.selectedWorktreeIds} + selectedWorktrees={selection.selectedWorktrees} + onSelectionGesture={selection.updateSelectionForGesture} onImmediateWorktreeActivate={handleImmediateWorktreeActivate} - onContextMenuSelect={selectForContextMenu} + onContextMenuSelect={selection.selectForContextMenu} repoMap={repoMap} defaultHostId={defaultHostId} worktreeMap={worktreeMap} worktreeLineageById={worktreeLineageById} workspaceLineageByChildKey={workspaceLineageByChildKey} - allRepoIds={allRepoIds} - onReorderHostSections={handleReorderHostSections} - onHostDragActiveChange={setHostDragActive} + allRepoIds={rowModel.allRepoIds} + onReorderHostSections={rowModel.handleReorderHostSections} + onHostDragActiveChange={rowModel.setHostDragActive} prCache={prCache} hostedReviewCache={hostedReviewCache} workspaceStatuses={workspaceStatuses} projectGrouping={projectGrouping} projectGroups={projectGroups} - onMoveWorktreeToStatus={moveWorktreeToStatus} - onMoveWorktreesToStatus={moveWorktreesToStatus} - onMoveWorktreesToStatusAtIndex={moveWorktreesToStatusAtIndex} - onPinWorktree={pinWorktree} - onPinWorktrees={pinWorktrees} - onDropWorktreesOnWorkspaceBoard={dropWorktreesOnWorkspaceBoard} + onMoveWorktreeToStatus={statusMutations.moveWorktreeToStatus} + onMoveWorktreesToStatus={statusMutations.moveWorktreesToStatus} + onMoveWorktreesToStatusAtIndex={statusMutations.moveWorktreesToStatusAtIndex} + onPinWorktree={statusMutations.pinWorktree} + onPinWorktrees={statusMutations.pinWorktrees} + onDropWorktreesOnWorkspaceBoard={statusMutations.dropWorktreesOnWorkspaceBoard} workspaceBoardOpen={workspaceBoardOpen} onWorkspaceBoardDragPreviewStart={onWorkspaceBoardDragPreviewStart} onWorkspaceBoardDragPreviewCommit={onWorkspaceBoardDragPreviewCommit} onWorkspaceBoardDragPreviewCancel={onWorkspaceBoardDragPreviewCancel} - shouldShowWorkspaceBoardDropIndicator={shouldShowWorkspaceBoardDropIndicator} - onReorderWorktrees={reorderWorktrees} + shouldShowWorkspaceBoardDropIndicator={ + statusMutations.shouldShowWorkspaceBoardDropIndicator + } + onReorderWorktrees={statusMutations.reorderWorktrees} scrollOffsetRef={scrollOffsetRef} scrollAnchorRef={scrollAnchorRef} /> diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx index 7b3a3eb6e5d..a4c5bf799eb 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx @@ -19,10 +19,14 @@ const mocks = vi.hoisted(() => ({ activeModal: 'worktree-visibility' as string | null, modalData: { repoId: 'repo-1' } as Record, closeModal: vi.fn(), + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn(), repos: [] as unknown[], updateRepo: vi.fn(), fetchWorktrees: vi.fn(), - detectedWorktreesByRepo: {} as Record + detectedWorktreesByRepo: {} as Record, + settings: {} as Record, + worktreeVisibilityDefaultsByHost: {} as Record } })) @@ -134,6 +138,8 @@ beforeEach(() => { mocks.state.modalData = { repoId: 'repo-1' } mocks.state.repos = [makeRepo()] mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected() } + mocks.state.settings = {} + mocks.state.worktreeVisibilityDefaultsByHost = {} mocks.state.updateRepo.mockImplementation( async (repoId: string, updates: Record) => { const repo = (mocks.state.repos as Repo[]).find((candidate) => candidate.id === repoId) @@ -172,10 +178,7 @@ async function renderDialog(): Promise { }) } -function deferred(): { - promise: Promise - resolve: (value: T) => void -} { +function deferred() { let resolve!: (value: T) => void const promise = new Promise((resolvePromise) => { resolve = resolvePromise @@ -184,7 +187,8 @@ function deferred(): { } function buttonWithText(text: string): HTMLButtonElement { - const button = [...document.querySelectorAll('button')].find( + // Why: the source rows' own Show / Hide segments share their text with the recovery rows. + const button = [...document.querySelectorAll('button:not([data-visibility])')].find( (candidate) => (candidate.textContent ?? '').trim() === text ) if (!button) { @@ -193,14 +197,36 @@ function buttonWithText(text: string): HTMLButtonElement { return button as HTMLButtonElement } -function sourceSwitch(label = 'Claude Code'): HTMLButtonElement { - const control = document.querySelector( - `[role="switch"][aria-label="Show current and future worktrees from ${label}"]` +function sourceSegment(label: string, visibility: 'show' | 'hide'): HTMLButtonElement { + const control = document.querySelector( + `[aria-label="Visibility for ${label}"] [data-visibility="${visibility}"]` ) if (!control) { - throw new Error(`No ${label} source switch`) + throw new Error(`No ${visibility} segment for ${label}`) + } + return control +} + +/** The Show segment, whose aria-checked mirrors whether the source is shown. */ +const sourceSwitch = (label = 'Claude Code'): HTMLButtonElement => sourceSegment(label, 'show') + +function sourceRow(label: string): HTMLElement { + const row = sourceSwitch(label).closest('[data-source-row]') + if (!row) { + throw new Error(`No ${label} source row`) + } + return row +} + +/** A project with no opinion of its own, under a global default that shows only Claude Code. */ +function inheritEverythingFromGlobal(): void { + mocks.state.repos = [makeRepo({ externalWorktreeVisibility: undefined })] + mocks.state.settings = { + worktreeVisibilityDefaults: { + external: 'hide', + sourcePreferences: { builtIn: { claude: 'show' } } + } } - return control as HTMLButtonElement } async function click(element: HTMLElement): Promise { @@ -218,6 +244,22 @@ async function setInputValue(input: HTMLInputElement, value: string): Promise { + it('links directly to the global visibility defaults', async () => { + await renderDialog() + + const manageGlobalSettings = buttonWithText('Manage in Global Settings') + expect(manageGlobalSettings.querySelector('.lucide-settings')).not.toBeNull() + await click(manageGlobalSettings) + + expect(mocks.state.closeModal).toHaveBeenCalledOnce() + expect(mocks.state.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'general', + repoId: null, + sectionId: 'general-global-worktree-visibility' + }) + expect(mocks.state.openSettingsPage).toHaveBeenCalledOnce() + }) + it('lists a hidden agent worktree with a repo-relative path', async () => { await renderDialog() @@ -577,20 +619,94 @@ describe('WorktreeVisibilityDialog', () => { expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { worktreeVisibilitySourcePreferences: { - builtIn: { claude: 'show', gsd: 'hide' } + builtIn: { claude: 'show' } } }) expect(mocks.state.closeModal).not.toHaveBeenCalled() }) + it('shows when source visibility is inherited from the global default', async () => { + inheritEverythingFromGlobal() + + await renderDialog() + + // Why: a row that still follows Global Settings says nothing extra — the segment is the whole story. + expect(sourceRow('Claude Code').textContent).not.toContain('Overriding global setting') + expect(sourceRow('GSD').textContent).not.toContain('Overriding global setting') + expect(sourceRow('Other locations').textContent).not.toContain('Overriding global setting') + expect(sourceSwitch('Claude Code').getAttribute('aria-checked')).toBe('true') + expect(sourceSwitch('GSD').getAttribute('aria-checked')).toBe('false') + }) + + it('lists what each inheritable source is set to in global settings', async () => { + inheritEverythingFromGlobal() + + await renderDialog() + + const intro = [...document.querySelectorAll('p')].find( + (candidate) => + candidate.textContent === 'These sources have a global setting you can override here:' + ) + expect(intro).not.toBeUndefined() + expect( + [...(intro?.nextElementSibling?.querySelectorAll('li') ?? [])].map((item) => item.textContent) + ).toEqual(['Claude CodeShow', 'GSDHide', 'Other locationsHide']) + expect(buttonWithText('Manage in Global Settings')).not.toBeNull() + }) + + it('names the global value an override is ignoring and reverts on the same control', async () => { + mocks.state.repos = [ + makeRepo({ + worktreeVisibilitySourcePreferences: { + builtIn: { claude: 'show', gsd: 'show' } + } + }) + ] + mocks.state.settings = { + worktreeVisibilityDefaults: { + external: 'hide', + sourcePreferences: { builtIn: { claude: 'hide', gsd: 'hide' } } + } + } + await renderDialog() + + expect(sourceRow('Claude Code').textContent).toContain('Overriding global setting: Hide') + await click(sourceSegment('Claude Code', 'hide')) + + expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { + agentWorktreeVisibility: null, + worktreeVisibilitySourcePreferences: { builtIn: { gsd: 'show' } } + }) + }) + + it('materializes the remaining legacy source override when one source reverts to global', async () => { + mocks.state.repos = [makeRepo({ agentWorktreeVisibility: 'show' })] + mocks.state.settings = { worktreeVisibilityDefaults: { external: 'hide' } } + await renderDialog() + + await click(sourceSegment('Claude Code', 'hide')) + + expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { + agentWorktreeVisibility: null, + worktreeVisibilitySourcePreferences: { builtIn: { gsd: 'show' } } + }) + }) + it('migrates legacy Always show to both built-in source rows', async () => { mocks.state.repos = [makeRepo({ agentWorktreeVisibility: 'show' })] + // Why: global must also be Show here, or hiding one row would revert to it instead of overriding. + mocks.state.settings = { + worktreeVisibilityDefaults: { + external: 'hide', + sourcePreferences: { builtIn: { claude: 'show', gsd: 'show' } } + } + } await renderDialog() expect(sourceSwitch('Claude Code').getAttribute('aria-checked')).toBe('true') expect(sourceSwitch('GSD').getAttribute('aria-checked')).toBe('true') - await click(sourceSwitch('Claude Code')) + await click(sourceSegment('Claude Code', 'hide')) expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { worktreeVisibilitySourcePreferences: { @@ -601,10 +717,12 @@ describe('WorktreeVisibilityDialog', () => { it('keeps ordinary non-Orca visibility on its own source row', async () => { mocks.state.repos = [makeRepo({ externalWorktreeVisibility: 'show' })] + // Why: global must also be Show here, or hiding the row would revert to it instead of overriding. + mocks.state.settings = { worktreeVisibilityDefaults: { external: 'show' } } await renderDialog() expect(sourceSwitch('Other locations').getAttribute('aria-checked')).toBe('true') - await click(sourceSwitch('Other locations')) + await click(sourceSegment('Other locations', 'hide')) expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { externalWorktreeVisibility: 'hide' @@ -787,4 +905,52 @@ describe('WorktreeVisibilityDialog', () => { expect(document.querySelector('button[aria-label="Remove /srv/alpha/team"]')).not.toBeNull() expect(document.querySelector('button[aria-label="Remove /srv/beta/team"]')).not.toBeNull() }) + + it('shows inherited global locations without repository removal controls', async () => { + mocks.state.settings = { + worktreeVisibilityDefaults: { + external: 'hide', + customSources: [{ id: 'global-team', rootPath: '/srv/global-team' }], + sourcePreferences: { custom: { 'global-team': 'show' } } + } + } + + await renderDialog() + + expect(sourceSwitch('/srv/global-team').getAttribute('aria-checked')).toBe('true') + expect(document.querySelector('button[aria-label="Remove /srv/global-team"]')).toBeNull() + await click(sourceSegment('/srv/global-team', 'hide')) + expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { + worktreeVisibilitySourcePreferences: { custom: { 'global-team': 'hide' } } + }) + }) + + it('resets global custom and Other locations without changing their global defaults', async () => { + mocks.state.repos = [ + makeRepo({ + externalWorktreeVisibility: 'show', + worktreeVisibilitySourcePreferences: { custom: { 'global-team': 'hide' } } + }) + ] + mocks.state.settings = { + worktreeVisibilityDefaults: { + external: 'hide', + customSources: [{ id: 'global-team', rootPath: '/srv/global-team' }], + sourcePreferences: { custom: { 'global-team': 'show' } } + } + } + await renderDialog() + + expect(sourceRow('/srv/global-team').textContent).toContain('Overriding global setting: Show') + expect(sourceRow('Other locations').textContent).toContain('Overriding global setting: Hide') + await click(sourceSegment('/srv/global-team', 'show')) + await click(sourceSegment('Other locations', 'hide')) + + expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { + worktreeVisibilitySourcePreferences: {} + }) + expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { + externalWorktreeVisibility: null + }) + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index e4a8ce45616..3ce9a85d275 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { Dialog, @@ -7,12 +7,7 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' import { isGitRepoKind } from '../../../../shared/repo-kind' -import { - effectiveExternalWorktreeVisibility, - isLegacyRepoForExternalWorktreeVisibility -} from '../../../../shared/external-worktree-visibility' import { translate } from '@/i18n/i18n' import { importNewExternalWorktreeInboxPaths, @@ -38,22 +33,29 @@ import WorktreeVisibilitySourceList, { import type { CustomWorktreeVisibilitySource, Repo } from '../../../../shared/repo-types' import { MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES, - buildWorktreeSourcePreferenceUpdate, - effectiveBuiltInWorktreeSourceVisibility, effectiveCustomWorktreeSourceVisibility, normalizeCustomWorktreeVisibilitySources, - normalizeWorktreeVisibilitySourcePreferences, - removeCustomWorktreeSourcePreference + normalizeWorktreeVisibilitySourcePreferences } from '../../../../shared/worktree/visibility-sources' +import { + buildWorktreeSourcePreferenceUpdate, + removeCustomWorktreeSourcePreference +} from '../../../../shared/worktree/visibility-source-preferences' import HiddenWorktreeRecoveryList from './HiddenWorktreeRecoveryList' import { worktreeVisibilityUpdateError } from './worktree-visibility-update-error' - -function getLatestRepoForScope(scope: string): Repo | null { - return ( - useAppStore.getState().repos.find((candidate) => getRepoHostIdentity(candidate) === scope) ?? - null - ) -} +import { useRepoOwnerVisibilityDefaults } from './use-repo-owner-visibility-defaults' +import { + getLatestRepoForVisibilityScope, + getRepoCustomWorktreeVisibilitySourceIds, + isDuplicateWorktreeVisibilitySource +} from './worktree-visibility-repo-sources' +import { WorktreeVisibilityGlobalSettingsLink } from './WorktreeVisibilityGlobalSettingsLink' +import { WorktreeVisibilityScanStatus } from './WorktreeVisibilityScanStatus' +import { + createWorktreeVisibilityUseGlobalMutation, + shouldUseGlobalWorktreeVisibility +} from './worktree-visibility-use-global' +import { createWorktreeVisibilitySourceMutation } from './worktree-visibility-source-mutation' export default function WorktreeVisibilityDialog(): React.JSX.Element | null { const activeModal = useAppStore((s) => s.activeModal) @@ -86,6 +88,8 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { const effectiveBusyPath = busyPath ?? (activeMutation?.kind === 'row' ? activeMutation.path : null) const effectivelyToggling = isToggling || activeMutation?.kind === 'toggle' + const visibilityDefaults = useRepoOwnerVisibilityDefaults(repo) + const removableSourceIds = useMemo(() => getRepoCustomWorktreeVisibilitySourceIds(repo), [repo]) useLayoutEffect(() => { currentMutationScopeRef.current = mutationScope @@ -193,7 +197,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { setIsToggling(true) try { const updated = await updateTargetRepo(repoId, updates) - const latestRepo = getLatestRepoForScope(mutationScope) + const latestRepo = getLatestRepoForVisibilityScope(mutationScope) if (!updated || !latestRepo || !isAccepted(latestRepo)) { if (currentMutationScopeRef.current === mutationScope) { setActionState({ @@ -222,45 +226,44 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { [mutationScope, refreshTargetRepo, repoId, updateTargetRepo] ) + const handleUseDefault = useCallback( + async (source: WorktreeVisibilitySourceRow) => { + if (!repo) { + return + } + const mutation = createWorktreeVisibilityUseGlobalMutation(repo, source, visibilityDefaults) + await commitSourceUpdate(mutation.updates, mutation.isAccepted) + }, + [commitSourceUpdate, repo, visibilityDefaults] + ) + const handleSourceToggle = useCallback( async (source: WorktreeVisibilitySourceRow, checked: boolean) => { if (!repo) { return } const visibility = checked ? 'show' : 'hide' - if (source.kind === 'other') { - await commitSourceUpdate( - { - externalWorktreeVisibility: visibility, - ...(checked ? { externalWorktreeDiscoverySuppressedAt: null } : {}) - }, - (latestRepo) => - effectiveExternalWorktreeVisibility( - latestRepo, - isLegacyRepoForExternalWorktreeVisibility(latestRepo) - ) === visibility + if ( + shouldUseGlobalWorktreeVisibility( + repo, + source, + visibility, + visibilityDefaults, + removableSourceIds ) + ) { + await handleUseDefault(source) return } - const match = - source.kind === 'built-in' - ? ({ kind: 'built-in', id: source.id } as const) - : ({ kind: 'custom', id: source.source.id } as const) - await commitSourceUpdate( - { - worktreeVisibilitySourcePreferences: buildWorktreeSourcePreferenceUpdate( - repo, - match, - visibility - ) - }, - (latestRepo) => - source.kind === 'built-in' - ? effectiveBuiltInWorktreeSourceVisibility(latestRepo, source.id) === visibility - : effectiveCustomWorktreeSourceVisibility(latestRepo, source.source.id) === visibility + const mutation = createWorktreeVisibilitySourceMutation( + repo, + source, + visibility, + visibilityDefaults ) + await commitSourceUpdate(mutation.updates, mutation.isAccepted) }, - [commitSourceUpdate, repo] + [commitSourceUpdate, handleUseDefault, removableSourceIds, repo, visibilityDefaults] ) const handleAddSource = useCallback( @@ -279,6 +282,9 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { if (!candidate) { return 'invalid-path' } + if (isDuplicateWorktreeVisibilitySource(repo, visibilityDefaults, candidate)) { + return 'duplicate-path' + } const next = normalizeCustomWorktreeVisibilitySources([...(existing ?? []), candidate]) if (!next || next.length !== (existing?.length ?? 0) + 1) { return 'duplicate-path' @@ -296,11 +302,11 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { normalizeCustomWorktreeVisibilitySources( latestRepo.customWorktreeVisibilitySources )?.some((source) => source.id === id) === true && - effectiveCustomWorktreeSourceVisibility(latestRepo, id) === 'hide' + effectiveCustomWorktreeSourceVisibility(latestRepo, id, visibilityDefaults) === 'hide' ) return saved ? 'added' : 'save-failed' }, - [commitSourceUpdate, repo] + [commitSourceUpdate, repo, visibilityDefaults] ) const handleRemoveSource = useCallback( @@ -351,40 +357,22 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { - {listState === 'checking' ? ( -

- {translate('auto.components.sidebar.WorktreeVisibilityDialog.a3f19c07d2', 'Checking…')} -

- ) : null} + - {listState === 'failed' ? ( -
-

- {translate( - 'auto.components.sidebar.WorktreeVisibilityDialog.b8d24e61f5', - "Could not list this repo's worktrees." - )} -

- -
- ) : null} + + + {translate( + 'auto.components.sidebar.WorktreeVisibilityDialog.openGlobalSettings', + 'Manage in Global Settings' + )} + + ) +} + +export function WorktreeVisibilityGlobalSettingsLink({ + repo, + visibilityDefaults +}: { + repo: Repo + visibilityDefaults?: WorktreeVisibilityDefaults +}): React.JSX.Element { + const inherited = listInheritedWorktreeVisibilitySources(repo, visibilityDefaults) + if (inherited.length === 0) { + return + } + + return ( +
+

+ {translate( + 'auto.components.sidebar.WorktreeVisibilityDialog.globalSettingsSources', + 'These sources have a global setting you can override here:' + )} +

+
    + {inherited.map(({ source, globalVisibility }) => ( +
  • + + {getWorktreeVisibilitySourceLabel(source)} + + + {worktreeVisibilityValueLabel(globalVisibility)} + +
  • + ))} +
+ +
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityScanStatus.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityScanStatus.tsx new file mode 100644 index 00000000000..4d24711df97 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityScanStatus.tsx @@ -0,0 +1,39 @@ +import type React from 'react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' + +type Props = { + state: 'checking' | 'ready' | 'failed' + retryDisabled: boolean + onRetry: () => void +} + +export function WorktreeVisibilityScanStatus({ + state, + retryDisabled, + onRetry +}: Props): React.JSX.Element | null { + if (state === 'checking') { + return ( +

+ {translate('auto.components.sidebar.WorktreeVisibilityDialog.a3f19c07d2', 'Checking…')} +

+ ) + } + if (state !== 'failed') { + return null + } + return ( +
+

+ {translate( + 'auto.components.sidebar.WorktreeVisibilityDialog.b8d24e61f5', + "Could not list this repo's worktrees." + )} +

+ +
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilitySourceAddForm.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceAddForm.tsx new file mode 100644 index 00000000000..7f3f5225f08 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceAddForm.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { translate } from '@/i18n/i18n' + +export type WorktreeVisibilitySourceAddResult = + | 'added' + | 'invalid-path' + | 'duplicate-path' + | 'limit' + | 'save-failed' + +export function WorktreeVisibilitySourceAddForm({ + disabled, + onAdd +}: { + disabled: boolean + onAdd: (rootPath: string) => Promise +}): React.JSX.Element { + const [rootPath, setRootPath] = useState('') + const [inputError, setInputError] = useState(null) + + const handleAdd = async (event: React.FormEvent) => { + event.preventDefault() + setInputError(null) + const result = await onAdd(rootPath) + if (result === 'added') { + setRootPath('') + return + } + if (result === 'save-failed') { + return + } + setInputError( + result === 'limit' + ? translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.limit', + 'Remove a custom location before adding another.' + ) + : result === 'duplicate-path' + ? translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.duplicatePath', + 'This location is already listed.' + ) + : translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.invalidPath', + 'Enter an absolute path for this host.' + ) + ) + } + + return ( +
void handleAdd(event)} + > + +
+ { + setRootPath(event.target.value) + setInputError(null) + }} + /> + +
+

+ {inputError ?? + translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.rootHelp', + 'Orca will recognize worktrees beneath this folder.' + )} +

+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.test.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.test.tsx new file mode 100644 index 00000000000..b4ef40f2aea --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ReactNode } from 'react' +import type { Repo } from '../../../../shared/repo-types' +import WorktreeVisibilitySourceList from './WorktreeVisibilitySourceList' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children} +})) + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('WorktreeVisibilitySourceList', () => { + it('offers a reset when a project override already matches the global value', async () => { + const onUseDefault = vi.fn() + const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: '#000000', + addedAt: 0, + worktreeVisibilitySourcePreferences: { builtIn: { claude: 'show' } } + } + act(() => { + root.render( + 'added'} + onRemove={async () => {}} + onToggle={async () => {}} + onUseDefault={onUseDefault} + /> + ) + }) + + const reset = container.querySelector( + 'button[aria-label="Use global for Claude Code"]' + ) + expect(reset).not.toBeNull() + expect(container.querySelectorAll('button[aria-label^="Use global for "]')).toHaveLength(1) + expect(reset?.closest('[data-source-row]')?.textContent).not.toContain( + 'Overriding global setting' + ) + await act(async () => reset?.click()) + + expect(onUseDefault).toHaveBeenCalledWith({ kind: 'built-in', id: 'claude' }) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx index ca23f122550..56b5e5a0856 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx @@ -1,22 +1,25 @@ -import React, { useMemo, useState } from 'react' -import { Trash2 } from 'lucide-react' +import React, { useMemo } from 'react' +import { Info, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Switch } from '@/components/ui/switch' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import type { BuiltInWorktreeVisibilitySourceId, CustomWorktreeVisibilitySource, + ExternalWorktreeVisibility, Repo } from '../../../../shared/repo-types' import type { DetectedWorktree } from '../../../../shared/worktree/types' +import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' import { createWorktreeVisibilitySourceMatcher, + effectiveDefaultBuiltInWorktreeSourceVisibility, + effectiveDefaultCustomWorktreeSourceVisibility, effectiveBuiltInWorktreeSourceVisibility, - effectiveCustomWorktreeSourceVisibility, normalizeCustomWorktreeVisibilitySources, + normalizeWorktreeVisibilitySourcePreferences, + resolveCustomWorktreeVisibilitySources, type WorktreeVisibilitySourceMatch } from '../../../../shared/worktree/visibility-sources' import { @@ -24,6 +27,18 @@ import { isLegacyRepoForExternalWorktreeVisibility } from '../../../../shared/external-worktree-visibility' import { getRuntimePathBasename } from '../../../../shared/cross-platform-path' +import { + getWorktreeVisibilityOverrideNotice, + getWorktreeVisibilitySourceNote, + getWorktreeVisibilitySourceProvenance, + worktreeVisibilityValueLabel +} from './worktree-visibility-source-provenance' +import { + WorktreeVisibilitySourceAddForm, + type WorktreeVisibilitySourceAddResult +} from './WorktreeVisibilitySourceAddForm' + +export type { WorktreeVisibilitySourceAddResult } export type WorktreeVisibilitySourceRow = | { kind: 'built-in'; id: BuiltInWorktreeVisibilitySourceId } @@ -31,22 +46,25 @@ export type WorktreeVisibilitySourceRow = | { kind: 'other' } type Props = { - repo: Repo - worktrees: readonly DetectedWorktree[] + repo?: Repo + worktrees?: readonly DetectedWorktree[] + visibilityDefaults?: WorktreeVisibilityDefaults + customSources?: readonly CustomWorktreeVisibilitySource[] + removableSourceIds?: ReadonlySet + showCounts?: boolean disabled: boolean + sourceDefaultsDisabled?: boolean onAdd: (rootPath: string) => Promise onRemove: (source: CustomWorktreeVisibilitySource) => Promise onToggle: (source: WorktreeVisibilitySourceRow, enabled: boolean) => Promise + onUseDefault?: (source: WorktreeVisibilitySourceRow) => Promise } -export type WorktreeVisibilitySourceAddResult = - | 'added' - | 'invalid-path' - | 'duplicate-path' - | 'limit' - | 'save-failed' +const EMPTY_VISIBILITY_DEFAULTS: WorktreeVisibilityDefaults = {} +const EMPTY_WORKTREES: readonly DetectedWorktree[] = [] +const VISIBILITY_SEGMENTS: readonly ExternalWorktreeVisibility[] = ['show', 'hide'] -function getSourceLabel(source: WorktreeVisibilitySourceRow): string { +export function getWorktreeVisibilitySourceLabel(source: WorktreeVisibilitySourceRow): string { if (source.kind === 'built-in') { return source.id === 'claude' ? translate('auto.components.sidebar.WorktreeVisibilitySourceList.claude', 'Claude Code') @@ -77,16 +95,33 @@ function getSourcePath(source: WorktreeVisibilitySourceRow): string { return `${source.source.rootPath.replace(/[\\/]+$/, '')}/*` } -function isSourceEnabled(repo: Repo, source: WorktreeVisibilitySourceRow): boolean { +function sourceVisibility( + repo: Repo | undefined, + source: WorktreeVisibilitySourceRow, + visibilityDefaults: WorktreeVisibilityDefaults, + repoCustomSourceIds: ReadonlySet +): ExternalWorktreeVisibility { if (source.kind === 'built-in') { - return effectiveBuiltInWorktreeSourceVisibility(repo, source.id) === 'show' + return repo + ? effectiveBuiltInWorktreeSourceVisibility(repo, source.id, visibilityDefaults) + : effectiveDefaultBuiltInWorktreeSourceVisibility(visibilityDefaults, source.id) } if (source.kind === 'custom') { - return effectiveCustomWorktreeSourceVisibility(repo, source.source.id) === 'show' + const explicit = repo + ? normalizeWorktreeVisibilitySourcePreferences(repo.worktreeVisibilitySourcePreferences) + ?.custom?.[source.source.id] + : undefined + return ( + explicit ?? + (repoCustomSourceIds.has(source.source.id) + ? 'hide' + : effectiveDefaultCustomWorktreeSourceVisibility(visibilityDefaults, source.source.id)) + ) } - return ( - effectiveExternalWorktreeVisibility(repo, isLegacyRepoForExternalWorktreeVisibility(repo)) === - 'show' + return effectiveExternalWorktreeVisibility( + repo ?? {}, + repo ? isLegacyRepoForExternalWorktreeVisibility(repo) : false, + visibilityDefaults ) } @@ -94,7 +129,7 @@ function sourceMatchKey(match: WorktreeVisibilitySourceMatch | null): string { return match ? `${match.kind}:${match.id}` : 'other' } -function sourceRowKey(source: WorktreeVisibilitySourceRow): string { +export function worktreeVisibilitySourceRowKey(source: WorktreeVisibilitySourceRow): string { return source.kind === 'custom' ? `custom:${source.source.id}` : source.kind === 'built-in' @@ -108,17 +143,36 @@ function getAccessibleSourceLabel(source: WorktreeVisibilitySourceRow, label: st export default function WorktreeVisibilitySourceList({ repo, - worktrees, + worktrees = EMPTY_WORKTREES, + visibilityDefaults = EMPTY_VISIBILITY_DEFAULTS, + customSources: providedCustomSources, + removableSourceIds, + showCounts = true, disabled, + sourceDefaultsDisabled = false, onAdd, onRemove, - onToggle + onToggle, + onUseDefault }: Props): React.JSX.Element { - const [rootPath, setRootPath] = useState('') - const [inputError, setInputError] = useState(null) const customSources = useMemo( - () => normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [], - [repo.customWorktreeVisibilitySources] + () => + normalizeCustomWorktreeVisibilitySources( + providedCustomSources ?? + (repo + ? resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults) + : visibilityDefaults.customSources) + ) ?? [], + [providedCustomSources, repo, visibilityDefaults] + ) + const repoCustomSourceIds = useMemo( + () => + new Set( + normalizeCustomWorktreeVisibilitySources(repo?.customWorktreeVisibilitySources)?.map( + (source) => source.id + ) ?? [] + ), + [repo?.customWorktreeVisibilitySources] ) const sources = useMemo( () => [ @@ -132,10 +186,10 @@ export default function WorktreeVisibilitySourceList({ const classify = useMemo( () => createWorktreeVisibilitySourceMatcher( - [repo.path, ...worktrees.map((worktree) => worktree.path)], + [...(repo ? [repo.path] : []), ...worktrees.map((worktree) => worktree.path)], customSources ), - [customSources, repo.path, worktrees] + [customSources, repo, worktrees] ) const sourceCounts = useMemo(() => { const counts = new Map() @@ -149,35 +203,6 @@ export default function WorktreeVisibilitySourceList({ return counts }, [classify, worktrees]) - const handleAdd = async (event: React.FormEvent) => { - event.preventDefault() - setInputError(null) - const result = await onAdd(rootPath) - if (result === 'added') { - setRootPath('') - return - } - if (result === 'save-failed') { - return - } - setInputError( - result === 'limit' - ? translate( - 'auto.components.sidebar.WorktreeVisibilitySourceList.limit', - 'Remove a custom location before adding another.' - ) - : result === 'duplicate-path' - ? translate( - 'auto.components.sidebar.WorktreeVisibilitySourceList.duplicatePath', - 'This location is already listed.' - ) - : translate( - 'auto.components.sidebar.WorktreeVisibilitySourceList.invalidPath', - 'Enter an absolute path for this host.' - ) - ) - } - return (
@@ -187,38 +212,57 @@ export default function WorktreeVisibilitySourceList({

{translate( 'auto.components.sidebar.WorktreeVisibilitySourceList.sourcesDescription', - 'Enabled sources include current and future worktrees in the sidebar.' + 'Shown sources include current and future worktrees in the sidebar.' )}

{sources.map((source, index) => { - const label = getSourceLabel(source) - const key = sourceRowKey(source) + const label = getWorktreeVisibilitySourceLabel(source) + const key = worktreeVisibilitySourceRowKey(source) const count = sourceCounts.get(key) ?? 0 const accessibleLabel = getAccessibleSourceLabel(source, label) + const sourceDisabled = disabled || (source.kind !== 'other' && sourceDefaultsDisabled) + const provenance = getWorktreeVisibilitySourceProvenance( + repo, + source, + visibilityDefaults, + repoCustomSourceIds + ) + const visibility = sourceVisibility(repo, source, visibilityDefaults, repoCustomSourceIds) + const note = getWorktreeVisibilitySourceNote(provenance) + const overrideNotice = getWorktreeVisibilityOverrideNotice(provenance, visibility) + const matchingOverride = + provenance?.kind === 'project-override' && provenance.globalVisibility === visibility return (
0 ? 'border-t border-border' : ''}`} + data-source-row={key} + className={`grid min-h-14 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2.5 gap-y-2 px-2.5 py-2 ${index > 0 ? 'border-t border-border' : ''}`} > {label} - - {translate( - 'auto.components.sidebar.WorktreeVisibilitySourceList.found', - '{{value0}} found', - { value0: count } - )} - + {showCounts ? ( + + {translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.found', + '{{value0}} found', + { value0: count } + )} + + ) : null} {getSourcePath(source)} + {note ? ( + {note} + ) : null} - {source.kind === 'custom' ? ( + {source.kind === 'custom' && + (!removableSourceIds || removableSourceIds.has(source.source.id)) ? ( + ) : null} + void onToggle(source, checked)} - /> + className="h-7" + onValueChange={(next) => { + // Why: re-picking the selected segment clears Radix's value; it is not a change. + if (next === 'show' || next === 'hide') { + void onToggle(source, next === 'show') + } + }} + > + {VISIBILITY_SEGMENTS.map((segment) => ( + + {worktreeVisibilityValueLabel(segment)} + + ))} + + {overrideNotice ? ( + + + {overrideNotice} + + ) : null}
) })} -
void handleAdd(event)} - > - -
- { - setRootPath(event.target.value) - setInputError(null) - }} - /> - -
-

- {inputError ?? - translate( - 'auto.components.sidebar.WorktreeVisibilitySourceList.rootHelp', - 'Orca will recognize worktrees beneath this folder.' - )} -

-
+
) diff --git a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts index 42411ff6fce..b4930d74742 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { const state = { @@ -85,6 +85,44 @@ describe('runWorktreeDeletesInParallel', () => { vi.mocked(showPreservedBranchBatchToast).mockClear() }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses one snapshot prune batch for a 100-workspace delete', async () => { + const begin = vi.fn(async (_args: { batchId: string }) => undefined) + const record = vi.fn(async (_args: { batchId: string; worktreeId: string }) => undefined) + const finish = vi.fn(async (_args: { batchId: string }) => undefined) + vi.stubGlobal('window', { + api: { + workspaceCleanup: { + beginRemovalSnapshotPruneBatch: begin, + recordRemovalSnapshotPrune: record, + finishRemovalSnapshotPruneBatch: finish + } + } + }) + const targets = Array.from({ length: 100 }, (_, index) => ({ + id: `wt-${index}`, + displayName: `workspace ${index}`, + repoId: `repo-${index % 10}`, + path: `/workspaces/${index}` + })) + + await expect(runDeletesForCurrentWorktrees(targets)).resolves.toHaveLength(100) + + expect(begin).toHaveBeenCalledOnce() + const batchId = begin.mock.calls[0]?.[0].batchId + expect(batchId).toEqual(expect.any(String)) + expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(100) + for (const call of mocks.state.removeWorktree.mock.calls) { + expect(call[2]).toMatchObject({ snapshotPruneBatchId: batchId }) + } + expect(finish).toHaveBeenCalledOnce() + expect(finish).toHaveBeenCalledWith({ batchId }) + expect(record).not.toHaveBeenCalled() + }) + it('starts every selected delete before waiting for earlier deletes to finish', async () => { const first = deferredDeleteResult() const second = deferredDeleteResult() diff --git a/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts b/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts index d530d2c6d17..71237a73149 100644 --- a/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts +++ b/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts @@ -1,3 +1,9 @@ +import type { + GlobalSettings, + WorktreeVisibilityDefaults +} from '../../../../shared/global-settings-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host' import type { Repo } from '../../../../shared/repo-types' import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types' import { getHiddenExternalWorktrees } from '../../../../shared/external-worktree-inbox' @@ -20,6 +26,8 @@ export function buildImportedWorktreesCardCandidates(args: { detectedWorktreesByRepo: Readonly> filterRepoIds?: readonly string[] forceVisibleRepoIds?: ReadonlySet + settings?: Pick | null + visibilityDefaultsByHost?: Partial> }): Map { const visibleRepoIds = args.visibleWorktrees ? new Set(args.visibleWorktrees.map((worktree) => worktree.repoId)) @@ -41,7 +49,12 @@ export function buildImportedWorktreesCardCandidates(args: { } const visibility = effectiveExternalWorktreeVisibility( repo, - isLegacyRepoForExternalWorktreeVisibility(repo) + isLegacyRepoForExternalWorktreeVisibility(repo), + getRepoOwnerWorktreeVisibilityDefaults( + repo, + args.settings, + args.visibilityDefaultsByHost ?? {} + ) ) if (visibility !== 'hide' && !args.forceVisibleRepoIds?.has(repo.id)) { continue diff --git a/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts b/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts index 8b7e3423dc7..c467707c4b0 100644 --- a/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts +++ b/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts @@ -1,3 +1,9 @@ +import type { + GlobalSettings, + WorktreeVisibilityDefaults +} from '../../../../shared/global-settings-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host' import type { Repo } from '../../../../shared/repo-types' import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types' import { getNewExternalWorktreeInboxWorktrees } from '../../../../shared/external-worktree-inbox' @@ -9,6 +15,8 @@ export function buildNewExternalWorktreesInboxCandidates(args: { visibleWorktrees?: readonly Worktree[] detectedWorktreesByRepo: Readonly> filterRepoIds?: readonly string[] + settings?: Pick | null + visibilityDefaultsByHost?: Partial> }): Map { const visibleRepoIds = args.visibleWorktrees ? new Set(args.visibleWorktrees.map((worktree) => worktree.repoId)) @@ -27,7 +35,12 @@ export function buildNewExternalWorktreesInboxCandidates(args: { } const inboxWorktrees = getNewExternalWorktreeInboxWorktrees( args.detectedWorktreesByRepo[repo.id], - repo + repo, + getRepoOwnerWorktreeVisibilityDefaults( + repo, + args.settings, + args.visibilityDefaultsByHost ?? {} + ) ) if (inboxWorktrees.length > 0) { candidates.set(repo.id, { repo, inboxWorktrees }) diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts index 5f7d294ec61..58da2de4511 100644 --- a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -3,7 +3,17 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' function readWorktreeListSource(): string { - return readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8') + return readFileSync( + fileURLToPath(new URL('./worktree-list/worktree-section-header-row.tsx', import.meta.url)), + 'utf8' + ) +} + +function readHeaderDragSource(): string { + return readFileSync( + fileURLToPath(new URL('./worktree-list/use-worktree-sidebar-header-drag.ts', import.meta.url)), + 'utf8' + ) } describe('Project Group header drag DOM source', () => { @@ -17,7 +27,7 @@ describe('Project Group header drag DOM source', () => { }) it('commits Project Group manual sorting through updateProjectGroup tabOrder', () => { - const source = readWorktreeListSource() + const source = readHeaderDragSource() expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)') expect(source).toContain('void updateProjectGroup(groupId, { tabOrder })') diff --git a/src/renderer/src/components/sidebar/use-repo-owner-visibility-defaults.ts b/src/renderer/src/components/sidebar/use-repo-owner-visibility-defaults.ts new file mode 100644 index 00000000000..e4b80c51147 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-repo-owner-visibility-defaults.ts @@ -0,0 +1,12 @@ +import { useAppStore } from '@/store' +import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host' + +export function useRepoOwnerVisibilityDefaults( + repo: Repo | null +): WorktreeVisibilityDefaults | undefined { + const settings = useAppStore((state) => state.settings) + const defaultsByHost = useAppStore((state) => state.worktreeVisibilityDefaultsByHost) + return repo ? getRepoOwnerWorktreeVisibilityDefaults(repo, settings, defaultsByHost) : undefined +} diff --git a/src/renderer/src/components/sidebar/worktree-delete-execution.ts b/src/renderer/src/components/sidebar/worktree-delete-execution.ts index f105acedcdf..7d0ca496a1a 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-execution.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-execution.ts @@ -14,6 +14,7 @@ import { showWorkspaceListChangedToast } from './stale-workspace-list-toast' import { showPreservedBranchBatchToast } from './preserved-branch-batch-toast' import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' import type { WorktreeDeleteWithToastOptions } from './worktree-delete-request' +import { beginWorktreeSnapshotPruneBatch } from './worktree-snapshot-prune-batch' // A failed delete usually means unresolved changes, so land on the diff panel. function viewWorktreeDiff(worktreeId: string): void { @@ -60,41 +61,56 @@ export async function runWorktreeDeletesInParallel( const preservedBranches: PreservedBranchCleanup[] = [] const aggregatePreservedBranches = uniqueTargets.length > 1 let listChanged = false - const groupResults = await Promise.all( - Array.from(groups.values()).map(async (group) => { - const deletedInGroup: string[] = [] - const failedInGroup: (typeof group)[number][] = [] - for (const target of group) { - // A queued target may be recreated while an earlier repo sibling is deleting. - const currentTarget = getWorktreeMapFromState(useAppStore.getState()).get(target.id) - if (!currentTarget || currentTarget.instanceId !== target.instanceId) { - useAppStore.getState().clearWorktreeDeleteState(target.id) - listChanged = true - continue - } - if (failedInGroup.some((failed) => isStrictDescendantPath(target.path, failed.path))) { - useAppStore.getState().clearWorktreeDeleteState(target.id) - continue - } - const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName, { - ...options, - focusSuccessorOnDelete: false, - suppressPreservedBranchToast: aggregatePreservedBranches, - onPreservedBranch: (branch) => { - preservedBranches.push(branch) - options.onPreservedBranch?.(branch) + const pendingSnapshotPruneBatch = + uniqueTargets.length > 1 ? beginWorktreeSnapshotPruneBatch() : null + const snapshotPruneBatch = pendingSnapshotPruneBatch ? await pendingSnapshotPruneBatch : null + let groupResults: string[][] + try { + groupResults = await Promise.all( + Array.from(groups.values()).map(async (group) => { + const deletedInGroup: string[] = [] + const failedInGroup: (typeof group)[number][] = [] + for (const target of group) { + // A queued target may be recreated while an earlier repo sibling is deleting. + const currentTarget = getWorktreeMapFromState(useAppStore.getState()).get(target.id) + if (!currentTarget || currentTarget.instanceId !== target.instanceId) { + useAppStore.getState().clearWorktreeDeleteState(target.id) + listChanged = true + continue + } + if (failedInGroup.some((failed) => isStrictDescendantPath(target.path, failed.path))) { + useAppStore.getState().clearWorktreeDeleteState(target.id) + continue + } + const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName, { + ...options, + focusSuccessorOnDelete: false, + suppressPreservedBranchToast: aggregatePreservedBranches, + ...(snapshotPruneBatch ? { snapshotPruneBatchId: snapshotPruneBatch.batchId } : {}), + onPreservedBranch: (branch) => { + preservedBranches.push(branch) + options.onPreservedBranch?.(branch) + } + }) + if (deleted) { + deletedInGroup.push(target.id) + } else { + // A failed child makes deleting its ancestor unsafe because the child lives below it. + failedInGroup.push(target) } - }) - if (deleted) { - deletedInGroup.push(target.id) - } else { - // A failed child makes deleting its ancestor unsafe because the child lives below it. - failedInGroup.push(target) } + return deletedInGroup + }) + ) + } finally { + if (snapshotPruneBatch) { + try { + await snapshotPruneBatch.finish() + } catch (error) { + console.warn('Failed to finish workspace snapshot prune batch:', error) } - return deletedInGroup - }) - ) + } + } if (listChanged) { showWorkspaceListChangedToast() } @@ -125,9 +141,14 @@ export function runWorktreeDeleteWithToast( const commitFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId) const focusSuccessor = options.focusSuccessorOnDelete !== false - const removal = options.suppressPreservedBranchToast - ? removeWorktree(worktreeId, options.force === true, { suppressPreservedBranchToast: true }) - : removeWorktree(worktreeId, options.force === true) + const removeOptions = { + ...(options.suppressPreservedBranchToast ? { suppressPreservedBranchToast: true } : {}), + ...(options.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } : {}) + } + const removal = + Object.keys(removeOptions).length > 0 + ? removeWorktree(worktreeId, options.force === true, removeOptions) + : removeWorktree(worktreeId, options.force === true) return removal .then((result) => { if (result.ok) { diff --git a/src/renderer/src/components/sidebar/worktree-delete-request.ts b/src/renderer/src/components/sidebar/worktree-delete-request.ts index df357d8c55e..f57a83a6b65 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-request.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-request.ts @@ -17,6 +17,7 @@ export type WorktreeDeleteWithToastOptions = { onForceDeleted?: (worktreeId: string) => void onPreservedBranch?: (branch: PreservedBranchCleanup) => void suppressPreservedBranchToast?: boolean + snapshotPruneBatchId?: string // Batch deletion commits one focus handoff after all targets settle. focusSuccessorOnDelete?: boolean } diff --git a/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts b/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts index 2b8bc7d68cd..edc4f41f47e 100644 --- a/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts +++ b/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts @@ -132,7 +132,9 @@ describe('getCyclableWorktreeIds', () => { describe('WorktreeList keyboard cycling', () => { it('cycles over the rendered rows instead of rebuilding a parallel layout', () => { const source = readFileSync( - fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), + fileURLToPath( + new URL('./worktree-list/use-worktree-list-keyboard-navigation.ts', import.meta.url) + ), 'utf8' ) const navigateWorktree = source.slice( diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index dacfcb3a139..7e6fe4b3b58 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -63,6 +63,13 @@ function readWorktreeListSource(): string { return readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8') } +function readSectionHeaderRowSource(): string { + return readFileSync( + fileURLToPath(new URL('./worktree-list/worktree-section-header-row.tsx', import.meta.url)), + 'utf8' + ) +} + const remoteRepo: Repo = { id: 'repo-remote', path: '/home/alice/orca', @@ -3985,7 +3992,7 @@ describe('buildRows workspace lineage nesting', () => { describe('WorktreeList header styles', () => { it('does not title-case workspace group labels', () => { - const source = readWorktreeListSource() + const source = readSectionHeaderRowSource() expect(source).not.toContain('leading-none capitalize') }) @@ -4005,7 +4012,7 @@ describe('WorktreeList header styles', () => { }) it('resolves repo header color from project group headers only', () => { - const source = readWorktreeListSource() + const source = readSectionHeaderRowSource() expect(source).toContain('resolveProjectGroupHeaderColor({') expect(source).toContain('headerKey: row.key') diff --git a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts index 9cda62a93b4..552515ff936 100644 --- a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' -import { - canKeepImportedWorktreesHidden, - getRenderRowKey, - getWorktreeDragGroups, - getWorktreeDragIndexes, - renderRowContainsWorktree -} from './WorktreeList' +import { canKeepImportedWorktreesHidden } from './worktree-list/imported-worktrees-card-visibility' +import { getRenderRowKey } from './worktree-list-virtual-rows' +import { getWorktreeDragGroups, getWorktreeDragIndexes } from './worktree-list/worktree-drag-groups' +import { renderRowContainsWorktree } from './worktree-list/render-row-worktree-lookup' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { Row } from './worktree-list-groups' diff --git a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts index f5b9d586f63..dd6f7c0689a 100644 --- a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { countRecordKeysByReference, - getScrollTopToRevealBounds, resolvePendingSidebarReveal, - WORKTREE_SIDEBAR_REVEAL_TOP_INSET, shouldAdjustWorktreeSidebarMeasuredRowScroll -} from './WorktreeList' +} from './worktree-list/worktree-sidebar-scroll-tuning' +import { + getScrollTopToRevealBounds, + WORKTREE_SIDEBAR_REVEAL_TOP_INSET +} from './worktree-sidebar-reveal' import { extractWorktreeVirtualRowIndexes, estimateRenderRowSize, diff --git a/src/renderer/src/components/sidebar/worktree-list-visible-refresh.test.ts b/src/renderer/src/components/sidebar/worktree-list-visible-refresh.test.ts index 1e519ac6d36..b99c0957d52 100644 --- a/src/renderer/src/components/sidebar/worktree-list-visible-refresh.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-visible-refresh.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { installWorktreeVisibleRefreshVisibilityListener } from './WorktreeList' +import { installWorktreeVisibleRefreshVisibilityListener } from './worktree-list/worktree-visible-refresh-listener' describe('installWorktreeVisibleRefreshVisibilityListener', () => { beforeEach(() => { diff --git a/src/renderer/src/components/sidebar/worktree-list/FolderPathStatusIndicator.tsx b/src/renderer/src/components/sidebar/worktree-list/FolderPathStatusIndicator.tsx new file mode 100644 index 00000000000..9b699c1086c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/FolderPathStatusIndicator.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { FolderX } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { + getFolderWorkspacePathStatusDescription, + getFolderWorkspacePathStatusTitle +} from '@/lib/folder-workspace-path-status' +import { + isConfirmedStaleFolderPathStatus, + type FolderWorkspacePathStatus +} from '../../../../../shared/folder-workspace-path-status' + +export function FolderPathStatusIndicator({ + status +}: { + status: FolderWorkspacePathStatus | null | undefined +}): React.JSX.Element | null { + const title = getFolderWorkspacePathStatusTitle(status) + if (!status || status.exists || !title) { + return null + } + const destructive = isConfirmedStaleFolderPathStatus(status) + return ( + + + + + + + +
+
{title}
+
+ {getFolderWorkspacePathStatusDescription(status)} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/HostSectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/HostSectionHeader.tsx new file mode 100644 index 00000000000..898719956f2 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/HostSectionHeader.tsx @@ -0,0 +1,161 @@ +import React from 'react' +import { AlertTriangle, ChevronDown, Loader2, Server, ServerOff } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { HostHeaderRow } from '../host-section-rows' +import { HostSectionHeaderMenu } from '../HostSectionHeaderMenu' + +function formatSectionActivityLabel(count: number, label: string): string { + return `${count} ${label}${count === 1 ? '' : 's'}` +} + +function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element { + const totalLabel = formatSectionActivityLabel(count, 'workspace') + + return ( + + + + + {count} + + + + {totalLabel} + + + + ) +} + +function HostHeaderHealthIcon({ + health +}: { + health: HostHeaderRow['health'] +}): React.JSX.Element | null { + // Why: only surface states needing attention; healthy is the silent default. + if (health === 'connecting') { + return + } + if (health === 'blocked' || health === 'error') { + return + } + return null +} + +function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } | null { + // Why: a blocked compatibility verdict earns a compact warning so the host stands out. + if (row.health === 'blocked') { + return { + text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'), + isWarning: true + } + } + // Why: auth-failed needs a worded status; the health icon alone doesn't tell the user to re-auth. + if (row.connectionStatus === 'auth-failed') { + return { + text: translate( + 'auto.components.sidebar.WorktreeList.hostAuthNeeded', + 'Authentication needed' + ), + isWarning: true + } + } + if (row.health === 'disconnected') { + return { + text: translate('auto.components.sidebar.WorktreeList.hostDisconnected', 'Disconnected'), + isWarning: false + } + } + // Why: show the transport detail only for remote hosts; it's noise under the local label. + if (row.kind !== 'local') { + return { text: row.detail, isWarning: false } + } + return null +} + +export function HostSectionHeader({ + row, + onToggle, + onDragPointerDown, + dragging +}: { + row: HostHeaderRow + onToggle: () => void + onDragPointerDown?: (event: React.PointerEvent) => void + dragging?: boolean +}): React.JSX.Element { + const isBlocked = row.health === 'blocked' + const isDisconnected = row.health === 'disconnected' + const detail = getHostHeaderDetail(row) + return ( +
+ {/* Why: outlined card + server glyph marks hosts as machines, not mere groups. */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + }} + > + {isDisconnected ? ( + + ) : ( + + )} + + {/* Why: badge hugs the label (like repo headers) instead of floating by the hover controls. */} +
+ + {row.label} + + {detail ? ( + + {detail.text} + + ) : null} + +
+
+ +
+ + + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx b/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx new file mode 100644 index 00000000000..cd46a80c3c7 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import { findRepoForHost } from '@/store/slices/repo-host-identity' +import type { AppState } from '@/store/types' +import type { Repo } from '../../../../../shared/repo-types' +import { ProjectGroupNameDialog } from '../ProjectGroupNameDialog' +import { ProjectGroupDeleteDialog } from '../ProjectGroupDeleteDialog' +import SuppressExternalWorktreeInboxDialog from '../SuppressExternalWorktreeInboxDialog' +import type { NewExternalWorktreesInboxActionState } from '../new-external-worktrees-inbox-actions' +import type { ProjectGroupDialogs } from './use-project-group-dialogs' + +export function SidebarWorktreeListDialogs({ + dialogs, + repos, + settings, + suppressExternalWorktreeInboxRepoId, + setSuppressExternalWorktreeInboxRepoId, + newExternalWorktreeInboxActionState, + onConfirmSuppressExternalWorktreeInbox, + onOpenWorktreeVisibility +}: { + dialogs: ProjectGroupDialogs + repos: readonly Repo[] + settings: AppState['settings'] + suppressExternalWorktreeInboxRepoId: string | null + setSuppressExternalWorktreeInboxRepoId: (repoId: string | null) => void + newExternalWorktreeInboxActionState: ReadonlyMap + onConfirmSuppressExternalWorktreeInbox: () => void + onOpenWorktreeVisibility: (repo: Repo) => void +}): React.JSX.Element { + const { nameDialog, setNameDialog, deleteDialog, setDeleteDialog } = dialogs + return ( + <> + { + if (!open) { + setNameDialog(null) + } + }} + onSubmit={dialogs.handleSubmitProjectGroupName} + /> + repo.id === suppressExternalWorktreeInboxRepoId)?.displayName ?? + '') + : '' + } + pending={ + suppressExternalWorktreeInboxRepoId + ? (newExternalWorktreeInboxActionState.get(suppressExternalWorktreeInboxRepoId) + ?.pending ?? false) + : false + } + onOpenChange={(open) => { + if (!open) { + setSuppressExternalWorktreeInboxRepoId(null) + } + }} + onConfirm={onConfirmSuppressExternalWorktreeInbox} + onOpenRecovery={() => { + if (!suppressExternalWorktreeInboxRepoId) { + return + } + const repo = findRepoForHost(repos, suppressExternalWorktreeInboxRepoId, { settings }) + setSuppressExternalWorktreeInboxRepoId(null) + if (repo) { + onOpenWorktreeVisibility(repo) + } + }} + /> + { + setDeleteDialog((current) => + current ? { ...current, removeContainedProjects } : current + ) + }} + onOpenChange={(open) => { + if (!open) { + setDeleteDialog(null) + } + }} + onConfirm={dialogs.handleConfirmDeleteProjectGroup} + /> + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx b/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx new file mode 100644 index 00000000000..05f98037c60 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { CircleX } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +export function SidebarWorktreeListEmptyState({ + hasFilters, + onClearFilters +}: { + hasFilters: boolean + onClearFilters: () => void +}): React.JSX.Element { + return ( +
+
+
+ + {translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')} + + {hasFilters && ( + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/VirtualizedWorktreeViewport.tsx b/src/renderer/src/components/sidebar/worktree-list/VirtualizedWorktreeViewport.tsx new file mode 100644 index 00000000000..8e1d8f8a7d1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/VirtualizedWorktreeViewport.tsx @@ -0,0 +1,413 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { WorktreeSidebarDropIndicator } from '../WorktreeSidebarDropIndicator' +import { WorktreeListScrollToTopButton } from '../WorktreeListScrollToTopButton' +import { useWorktreeListScrollToTop } from '../use-worktree-list-scroll-to-top' +import { getActiveDescendantOptionId } from './active-descendant-option' +import { buildRenderableRows } from './renderable-rows' +import { useFolderWorkspacePathStatusRows } from './use-folder-workspace-path-status-rows' +import { useGroupToggleWithScrollAnchor } from './use-group-toggle-with-scroll-anchor' +import { usePendingSidebarReveal } from './use-pending-sidebar-reveal' +import { usePrimaryActiveWorktreeRow } from './use-primary-active-worktree-row' +import { useSidebarRevealHighlight } from './use-sidebar-reveal-highlight' +import { useVirtualRowMeasurementSync } from './use-virtual-row-measurement-sync' +import { useVisiblePrRefreshReporting } from './use-visible-pr-refresh-reporting' +import { useWorkspaceStatusRowDrag } from './use-workspace-status-row-drag' +import { useWorktreeDragRuntime } from './use-worktree-drag-runtime' +import { useWorktreeDragSession } from './use-worktree-drag-session' +import { useWorktreeDocumentDrop } from './use-worktree-document-drop' +import { useWorktreeLineageDropCommit } from './use-worktree-lineage-drop-commit' +import { useWorktreeListKeyboardNavigation } from './use-worktree-list-keyboard-navigation' +import { useWorktreeListVirtualizer } from './use-worktree-list-virtualizer' +import { useWorktreeNativeDrag } from './use-worktree-native-drag' +import { useWorktreePointerDrag } from './use-worktree-pointer-drag' +import { useWorktreeSidebarHeaderDrag } from './use-worktree-sidebar-header-drag' +import { useWorktreeSidebarScrollSuppression } from './use-worktree-sidebar-scroll-suppression' +import { + EMPTY_PROJECT_GROUPS, + type VirtualizedWorktreeViewportProps +} from './virtualized-worktree-viewport-props' +import { WORKTREE_SIDEBAR_SCROLL_STYLE } from './worktree-sidebar-scroll-tuning' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import { buildWorktreeVirtualRowContext } from './worktree-virtual-row-context' +import { renderWorktreeVirtualRow } from './worktree-virtual-row-dispatch' + +export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport( + props: VirtualizedWorktreeViewportProps +) { + const { + rows, + groupBy, + pinnedDisplayPolicy, + activeWorktreeId, + collapsedGroups, + toggleGroup, + repoMap, + worktreeMap, + worktreeLineageById, + workspaceStatuses, + projectGroups = EMPTY_PROJECT_GROUPS, + scrollOffsetRef, + scrollAnchorRef + } = props + const scrollRef = useRef(null) + // Why: callback-ref only mutates scrollRef; state re-runs the scroll-to-top listener attach. + const [scrollElement, setScrollElement] = useState(null) + const settings = useAppStore((s) => s.settings) + const worktreeVisibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true + + const reveal = useSidebarRevealHighlight() + const scrollSuppression = useWorktreeSidebarScrollSuppression(scrollRef) + const { markDirectScrollInput, markScrollMovement } = scrollSuppression + + const renderRows = useMemo(() => buildRenderableRows(rows), [rows]) + const firstHeaderIndex = useMemo( + () => renderRows.findIndex((row) => row.type === 'header' || row.type === 'host-header'), + [renderRows] + ) + const folderBackedProjectGroupIds = useMemo( + () => + new Set( + projectGroups + .filter((group) => group.createdFrom === 'folder-scan') + .map((group) => group.id) + ), + [projectGroups] + ) + + const headerDrag = useWorktreeSidebarHeaderDrag({ + rows, + renderRows, + firstHeaderIndex, + allRepoIds: props.allRepoIds, + repoMap, + projectGroups, + groupBy, + projectOrderBy: props.projectOrderBy, + scrollRef, + onReorderHostSections: props.onReorderHostSections, + onHostDragActiveChange: props.onHostDragActiveChange, + suppressMeasurementAdjustmentUntilRef: scrollSuppression.suppressMeasurementAdjustmentUntilRef, + directScrollInputUntilRef: scrollSuppression.directScrollInputUntilRef + }) + + const session = useWorktreeDragSession({ rows, scrollRef }) + const lineageDrop = useWorktreeLineageDropCommit({ + repoMap, + worktreeMap, + worktreeLineageById, + worktreeDragGroups: session.worktreeDragGroups + }) + const runtime = useWorktreeDragRuntime({ + worktreeDragSessionRef: session.worktreeDragSessionRef, + statusDropAnchorsRef: session.statusDropAnchorsRef, + onWorkspaceBoardDragPreviewCancel: props.onWorkspaceBoardDragPreviewCancel + }) + + const primaryActive = usePrimaryActiveWorktreeRow({ + rows, + activeWorktreeId, + pinnedDisplayPolicy, + onImmediateWorktreeActivate: props.onImmediateWorktreeActivate + }) + + const getCachedFolderWorkspacePathStatus = useFolderWorkspacePathStatusRows({ + allRepoIds: props.allRepoIds, + repoMap, + projectGroups, + folderWorkspaces: props.folderWorkspaces, + sshConnectionStates + }) + + const { showScrollToTop, scrollToTop } = useWorktreeListScrollToTop({ + scrollElement, + onUserScrollIntent: markDirectScrollInput + }) + + const virtualization = useWorktreeListVirtualizer({ + renderRows, + firstHeaderIndex, + scrollRef, + scrollOffsetRef, + suppressMeasurementAdjustmentUntilRef: scrollSuppression.suppressMeasurementAdjustmentUntilRef + }) + + usePendingSidebarReveal({ + pendingRevealWorktree: props.pendingRevealWorktree, + pendingRevealSidebarRow: props.pendingRevealSidebarRow, + clearPendingRevealWorktreeId: props.clearPendingRevealWorktreeId, + clearPendingRevealSidebarRow: props.clearPendingRevealSidebarRow, + agentSendTargetWorktreeId: props.agentSendTargetWorktreeId, + renderRows, + virtualizer: virtualization.virtualizer, + scrollRef, + worktrees: props.worktrees, + folderWorkspaces: props.folderWorkspaces, + repoMap, + worktreeMap, + worktreeLineageById, + collapsedGroups, + toggleGroup, + groupBy, + pinnedDisplayPolicy, + defaultHostId: props.defaultHostId, + prCache: props.prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping: props.projectGrouping, + flashRevealedRow: reveal.flashRevealedRow, + markRevealScroll: scrollSuppression.markRevealScroll, + schedulePendingRevealFrame: reveal.schedulePendingRevealFrame, + cancelPendingRevealFrames: reveal.cancelPendingRevealFrames + }) + + const { virtualItems, measureVirtualRowElement } = useVirtualRowMeasurementSync({ + renderRows, + virtualization, + scrollRef, + scrollOffsetRef, + scrollAnchorRef, + hasDirectScrollInput: scrollSuppression.hasDirectScrollInput, + shouldSkipScrollAnchorRestore: scrollSuppression.shouldSkipScrollAnchorRestore + }) + + const { toggleGroupWithScrollAnchor, getLineageToggleHandler } = useGroupToggleWithScrollAnchor({ + scrollRef, + toggleGroup + }) + + const { handleContainerKeyDown } = useWorktreeListKeyboardNavigation({ + rows, + renderRows, + activeWorktreeId, + pinnedDisplayPolicy, + virtualizer: virtualization.virtualizer, + scrollRef, + activeModal: props.activeModal, + markDirectScrollInput + }) + + const dropCtx = useMemo( + () => ({ + scrollRef, + workspaceStatuses, + worktreeDragGroups: session.worktreeDragGroups, + worktreeDragUnitGroups: session.worktreeDragUnitGroups, + computeWorktreeDrop: session.computeWorktreeDrop, + computeWorktreeStatusDrop: session.computeWorktreeStatusDrop, + refreshWorktreeDragSession: session.refreshWorktreeDragSession, + getEligibleLineageDropTarget: lineageDrop.getEligibleLineageDropTarget, + commitWorktreeLineageParentDrop: lineageDrop.commitWorktreeLineageParentDrop, + clearReorderedWorktreeParents: lineageDrop.clearReorderedWorktreeParents, + clearWorktreeDrag: runtime.clearWorktreeDrag, + onMoveWorktreesToStatus: props.onMoveWorktreesToStatus, + onMoveWorktreesToStatusAtIndex: props.onMoveWorktreesToStatusAtIndex, + onReorderWorktrees: props.onReorderWorktrees, + onPinWorktrees: props.onPinWorktrees + }), + [ + lineageDrop.clearReorderedWorktreeParents, + lineageDrop.commitWorktreeLineageParentDrop, + lineageDrop.getEligibleLineageDropTarget, + props.onMoveWorktreesToStatus, + props.onMoveWorktreesToStatusAtIndex, + props.onPinWorktrees, + props.onReorderWorktrees, + runtime.clearWorktreeDrag, + session.computeWorktreeDrop, + session.computeWorktreeStatusDrop, + session.refreshWorktreeDragSession, + session.worktreeDragGroups, + session.worktreeDragUnitGroups, + workspaceStatuses + ] + ) + + const { handleWorktreeRowPointerDown, handleWorktreeRowClickCapture } = useWorktreePointerDrag({ + ctx: dropCtx, + session, + runtime, + scrollRef, + markScrollMovement, + selectedWorktreeIds: props.selectedWorktreeIds, + selectedWorktrees: props.selectedWorktrees, + workspaceBoardOpen: props.workspaceBoardOpen, + onWorkspaceBoardDragPreviewStart: props.onWorkspaceBoardDragPreviewStart, + onWorkspaceBoardDragPreviewCommit: props.onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard: props.onDropWorktreesOnWorkspaceBoard, + shouldShowWorkspaceBoardDropIndicator: props.shouldShowWorkspaceBoardDropIndicator + }) + const nativeDrag = useWorktreeNativeDrag({ + ctx: dropCtx, + session, + runtime, + scrollRef, + markScrollMovement + }) + useWorktreeDocumentDrop({ + ctx: dropCtx, + worktreeDragSessionRef: session.worktreeDragSessionRef + }) + const statusDrag = useWorkspaceStatusRowDrag({ + ctx: dropCtx, + session, + runtime, + scrollRef, + rows, + groupBy, + onMoveWorktreeToStatus: props.onMoveWorktreeToStatus, + onPinWorktree: props.onPinWorktree + }) + + useVisiblePrRefreshReporting({ + currentWorktreeId: props.currentWorktreeId, + worktreeMap, + groupBy, + newCardStyle, + renderRows, + virtualItems, + scrollRef + }) + + // Why: a callback ref that changes identity is re-invoked with null on every render, which + // would run the teardown below mid-drag; depend only on the stable teardown callbacks. + const { cancelPendingRevealFrames, clearRevealHighlightFrame, clearRevealHighlightTimeout } = + reveal + const { clearWorktreeDrag } = runtime + const setScrollRootRef = useCallback( + (node: HTMLDivElement | null) => { + if (node === null && scrollRef.current !== null) { + // Why: drag previews, autoscroll frames, and reveal snapshots are tied to the scroll root; clear them before it unmounts. + cancelPendingRevealFrames() + clearRevealHighlightFrame() + clearRevealHighlightTimeout() + clearWorktreeDrag() + } + scrollRef.current = node + setScrollElement(node) + }, + [ + cancelPendingRevealFrames, + clearRevealHighlightFrame, + clearRevealHighlightTimeout, + clearWorktreeDrag + ] + ) + const handleScrollPointerDown = useCallback( + (event: React.PointerEvent) => { + const scrollbarWidth = event.currentTarget.offsetWidth - event.currentTarget.clientWidth + if (scrollbarWidth <= 0) { + return + } + const rect = event.currentTarget.getBoundingClientRect() + if (event.clientX >= rect.right - scrollbarWidth) { + markDirectScrollInput() + } + }, + [markDirectScrollInput] + ) + const handleScroll = useCallback(() => { + markScrollMovement() + }, [markScrollMovement]) + + const rowContext = buildWorktreeVirtualRowContext({ + props, + renderRows, + firstHeaderIndex, + virtualization, + measureVirtualRowElement, + settings, + worktreeVisibilityDefaultsByHost, + sshConnectionStates, + newCardStyle, + folderBackedProjectGroupIds, + projectGroups, + session, + runtime, + primaryActive, + reveal, + statusDrag, + nativeDrag, + headerDrag, + getCachedFolderWorkspacePathStatus, + getLineageToggleHandler, + toggleGroupWithScrollAnchor, + onRowClickCapture: handleWorktreeRowClickCapture, + onRowPointerDown: handleWorktreeRowPointerDown + }) + + return ( +
+
+
+ {headerDrag.canReorderRepoHeaders && + headerDrag.repoDrag.state.draggingRepoId !== null && + headerDrag.repoDrag.state.dropIndicatorY !== null ? ( + + ) : null} + {headerDrag.canReorderProjectGroupHeaders && + headerDrag.projectGroupDrag.state.draggingGroupId !== null && + headerDrag.projectGroupDrag.state.dropIndicatorY !== null ? ( + + ) : null} + {headerDrag.hostDrag.state.draggingHostId !== null && + headerDrag.hostDrag.state.dropIndicatorY !== null ? ( + + ) : null} + {runtime.worktreeDragState.draggingWorktreeId !== null && + runtime.worktreeDragState.dropIndicatorY !== null ? ( + + ) : null} + {virtualItems.map((vItem) => { + const row = renderRows[vItem.index] + return row ? renderWorktreeVirtualRow(rowContext, row, vItem) : null + })} +
+
+ {showScrollToTop ? : null} +
+ ) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/active-descendant-option.ts b/src/renderer/src/components/sidebar/worktree-list/active-descendant-option.ts new file mode 100644 index 00000000000..b52614cfe4c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/active-descendant-option.ts @@ -0,0 +1,67 @@ +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import type { RenderRow } from '../worktree-list-virtual-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import { isPinnedWorktreeRow } from './render-row-item-rows' +import { getRenderRowWorktreeItem, renderRowContainsWorktree } from './render-row-worktree-lookup' +import { getWorktreeOptionId } from './worktree-option-dom' + +export function getRenderRowOptionId( + row: RenderRow | undefined, + worktreeId?: string | null +): string | undefined { + if (!row) { + return undefined + } + if (row.type === 'lineage-group') { + const targetRow = worktreeId ? row.rows.find((item) => item.worktree.id === worktreeId) : null + return getWorktreeOptionId((targetRow ?? row.rows[0])?.rowKey ?? row.key) + } + if (row.type === 'item') { + return getWorktreeOptionId(row.rowKey) + } + if (row.type === 'folder-workspace') { + return getWorktreeOptionId(folderWorkspaceKey(row.folderWorkspace.id)) + } + return undefined +} + +export function getActiveDescendantOptionId(args: { + activeWorktreeId: string | null + primaryActiveRowKey?: string + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + renderRows: readonly RenderRow[] + virtualItems: readonly { index: number }[] +}): string | undefined { + if (args.activeWorktreeId === null) { + return undefined + } + if (args.primaryActiveRowKey) { + const primaryOptionId = getWorktreeOptionId(args.primaryActiveRowKey) + for (const item of args.virtualItems) { + const row = args.renderRows[item.index] + if (row && getRenderRowOptionId(row, args.activeWorktreeId) === primaryOptionId) { + return primaryOptionId + } + } + } + let fallbackOptionId: string | undefined + for (const item of args.virtualItems) { + const row = args.renderRows[item.index] + if (row && renderRowContainsWorktree(row, args.activeWorktreeId)) { + const optionId = getRenderRowOptionId(row, args.activeWorktreeId) + if (!optionId) { + continue + } + const itemRow = getRenderRowWorktreeItem(row, args.activeWorktreeId) + if ( + args.pinnedDisplayPolicy === 'duplicate-in-groups' && + itemRow && + !isPinnedWorktreeRow(itemRow) + ) { + return optionId + } + fallbackOptionId ??= optionId + } + } + return fallbackOptionId +} diff --git a/src/renderer/src/components/sidebar/worktree-list/folder-workspace-virtual-row.tsx b/src/renderer/src/components/sidebar/worktree-list/folder-workspace-virtual-row.tsx new file mode 100644 index 00000000000..eb3dc2f3556 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/folder-workspace-virtual-row.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import type { AppState } from '@/store/types' +import type { Repo } from '../../../../../shared/repo-types' +import type { + WorkspaceLineage, + WorktreeLineage +} from '../../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { FolderWorkspacePathStatus } from '../../../../../shared/folder-workspace-path-status' +import { isConfirmedStaleFolderPathStatus } from '../../../../../shared/folder-workspace-path-status' +import { folderWorkspaceToWorktree } from '../../../../../shared/folder-workspace-worktree' +import WorktreeCard from '../WorktreeCard' +import type { WorktreeGroupBy } from '../worktree-list-groups' +import { getVirtualRowTransform } from '../worktree-list-virtual-rows' +import { getFolderWorkspaceRowGeometry } from '../worktree-list-indentation' +import { getFolderWorkspaceCardPrDisplay } from '../folder-workspace-card-pr-display' +import { FolderPathStatusIndicator } from './FolderPathStatusIndicator' +import type { FolderWorkspaceItemRow } from './render-row-item-rows' +import { getWorktreeOptionId } from './worktree-option-dom' + +export type FolderWorkspaceRowContext = { + groupBy: WorktreeGroupBy + newCardStyle: boolean + settings: AppState['settings'] + activeWorktreeId: string | null + currentWorktreeId: string | null + selectedWorktreeIds: ReadonlySet + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + workspaceLineageByChildKey: Record + prCache: AppState['prCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null + getCachedFolderWorkspacePathStatus: (request: { + scope: 'folder-workspace' + folderWorkspaceId: string + }) => FolderWorkspacePathStatus | null + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + onImmediateActivate: (worktreeId: string, rowKey: string | undefined) => void + onRowClickCapture: (event: React.MouseEvent) => void + onRowPointerDown: ( + event: React.PointerEvent, + worktreeId: string, + rowKey: string + ) => void +} + +export function renderFolderWorkspaceVirtualRow(args: { + ctx: FolderWorkspaceRowContext + row: FolderWorkspaceItemRow + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void +}): React.JSX.Element { + const { ctx, row, vItem } = args + const folderWorktree = folderWorkspaceToWorktree(row.folderWorkspace) + const pathStatus = ctx.getCachedFolderWorkspacePathStatus({ + scope: 'folder-workspace', + folderWorkspaceId: row.folderWorkspace.id + }) + const activationDisabled = + pathStatus?.exists === false && + (isConfirmedStaleFolderPathStatus(pathStatus) || pathStatus.reason === 'ambiguous-connection') + const folderPrDisplay = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: row.folderWorkspace.id, + workspaceLineageByChildKey: ctx.workspaceLineageByChildKey, + worktreeLineageById: ctx.worktreeLineageById, + worktreeMap: ctx.worktreeMap, + repoMap: ctx.repoMap, + hostedReviewCache: ctx.hostedReviewCache, + prCache: ctx.prCache, + settings: ctx.settings + }) + const { surfaceInset, cardContentIndent } = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: ctx.newCardStyle, + isFolderBackedWorkspaceChild: + ctx.groupBy === 'repo' && row.projectGroup.createdFrom === 'folder-scan', + isGrouped: ctx.groupBy !== 'none', + groupDepth: row.groupDepth, + lineageDepth: row.depth + }) + return ( +
ctx.onRowPointerDown(event, folderWorktree.id, folderWorktree.id)} + > +
0 ? { paddingLeft: surfaceInset } : undefined} + > + +
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/imported-worktrees-card-visibility.ts b/src/renderer/src/components/sidebar/worktree-list/imported-worktrees-card-visibility.ts new file mode 100644 index 00000000000..bfb7b38b039 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/imported-worktrees-card-visibility.ts @@ -0,0 +1,9 @@ +import type { Row } from '../worktree-list-groups' +import type { ImportedWorktreeCardActionState } from '../imported-worktrees-card-actions' + +export function canKeepImportedWorktreesHidden( + row: Extract, + actionState: ImportedWorktreeCardActionState | undefined +): boolean { + return row.placement === 'repo-group' && actionState?.forceVisible !== true +} diff --git a/src/renderer/src/components/sidebar/worktree-list/mounted-row-reveal.ts b/src/renderer/src/components/sidebar/worktree-list/mounted-row-reveal.ts new file mode 100644 index 00000000000..b3b1436211d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/mounted-row-reveal.ts @@ -0,0 +1,35 @@ +import { revealElementInScrollContainer } from '../worktree-sidebar-reveal' +import { getMountedWorktreeOptions, getWorktreeOptionId } from './worktree-option-dom' + +export function revealMountedWorktreeElement( + container: HTMLElement, + worktreeId: string, + behavior: ScrollBehavior, + optionId?: string, + onScrollIssued?: (targetTop: number) => void +): HTMLElement | null { + const element = optionId + ? document.getElementById(optionId) + : getMountedWorktreeOptions(worktreeId, container)[0] + if (!element || !container.contains(element)) { + return null + } + return revealElementInScrollContainer(container, element, behavior, onScrollIssued) + ? element + : null +} + +export function revealMountedSidebarRowElement( + container: HTMLElement, + rowKey: string, + behavior: ScrollBehavior, + onScrollIssued?: (targetTop: number) => void +): HTMLElement | null { + const element = document.getElementById(getWorktreeOptionId(rowKey)) + if (!element || !container.contains(element)) { + return null + } + return revealElementInScrollContainer(container, element, behavior, onScrollIssued) + ? element + : null +} diff --git a/src/renderer/src/components/sidebar/worktree-list/pending-sidebar-reveal-inputs.ts b/src/renderer/src/components/sidebar/worktree-list/pending-sidebar-reveal-inputs.ts new file mode 100644 index 00000000000..c29b08bd17f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/pending-sidebar-reveal-inputs.ts @@ -0,0 +1,116 @@ +import type React from 'react' +import type { Virtualizer } from '@tanstack/react-virtual' +import type { AppState } from '@/store/types' +import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' +import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import { getWorktreeExecutionHostId } from '../../../../../shared/execution-host' +import type { RenderRow } from '../worktree-list-virtual-rows' +import { + getGroupKeysForWorktree, + getLineageGroupKey, + type PinnedWorktreeDisplayPolicy, + type ProjectGroupingModel, + type WorktreeGroupBy +} from '../worktree-list-groups' +import { getWorktreeLineageAncestors } from '../worktree-lineage-projection' +import { getFolderWorkspaceRevealGroupKeys } from '../worktree-list-folder-reveal' +import { getPinnedWorktreeRevealCollapsedGroupKeys } from './sidebar-row-reveal-ancestors' + +export const MAX_REVEAL_RETRIES = 8 + +export type PendingSidebarRevealArgs = { + pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null + clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void + agentSendTargetWorktreeId: string | null + renderRows: RenderRow[] + virtualizer: Virtualizer + scrollRef: React.RefObject + worktrees: Worktree[] + folderWorkspaces: readonly FolderWorkspace[] + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + collapsedGroups: Set + toggleGroup: (key: string) => void + groupBy: WorktreeGroupBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + defaultHostId: ExecutionHostId + prCache: AppState['prCache'] | null + workspaceStatuses: readonly WorkspaceStatusDefinition[] + settings: AppState['settings'] + projectGroups: readonly ProjectGroup[] + projectGrouping?: ProjectGroupingModel + flashRevealedRow: (rowKey: string) => void + markRevealScroll: (targetTop: number) => void + schedulePendingRevealFrame: (callback: FrameRequestCallback) => void + cancelPendingRevealFrames: () => void +} + +// Expand whatever collapsed section hides the reveal target, then scroll to it. +export function expandGroupsForWorktreeReveal( + args: PendingSidebarRevealArgs, + worktreeId: string +): void { + const folderGroupKeys = getFolderWorkspaceRevealGroupKeys( + worktreeId, + args.folderWorkspaces, + args.projectGroups + ) + if (folderGroupKeys.length > 0) { + for (const groupKey of folderGroupKeys) { + if (args.collapsedGroups.has(groupKey)) { + args.toggleGroup(groupKey) + } + } + return + } + const targetWorktree = args.worktrees.find((w) => w.id === worktreeId) + if (!targetWorktree) { + return + } + const targetRepo = args.repoMap.get(targetWorktree.repoId) + const hostGroupKey = `host:${getWorktreeExecutionHostId(targetWorktree, targetRepo, args.defaultHostId)}` + if (args.collapsedGroups.has(hostGroupKey)) { + args.toggleGroup(hostGroupKey) + } + + for (const parent of getWorktreeLineageAncestors( + targetWorktree, + args.worktreeLineageById, + args.worktreeMap + )) { + const lineageGroupKey = getLineageGroupKey(parent.id) + if (args.collapsedGroups.has(lineageGroupKey)) { + args.toggleGroup(lineageGroupKey) + } + } + + const groupKeys = + targetWorktree.isPinned && args.pinnedDisplayPolicy === 'single-location' + ? getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree: targetWorktree, + collapsedGroups: args.collapsedGroups + }) + : getGroupKeysForWorktree( + args.groupBy, + targetWorktree, + args.repoMap, + args.prCache, + args.workspaceStatuses, + args.settings, + args.projectGroups, + args.projectGrouping + ) + for (const groupKey of groupKeys) { + if (args.collapsedGroups.has(groupKey)) { + args.toggleGroup(groupKey) + } + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/pointer-drop-status-target.ts b/src/renderer/src/components/sidebar/worktree-list/pointer-drop-status-target.ts new file mode 100644 index 00000000000..46e20c56234 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/pointer-drop-status-target.ts @@ -0,0 +1,56 @@ +import type { + WorkspaceStatus, + WorkspaceStatusDefinition +} from '../../../../../shared/worktree/types' +import { getWorkspaceStatusFromGroupKey } from '../workspace-status' +import { getWorktreeLineageDropTargetId } from '../worktree-lineage-drag-drop' +import type { WorktreeSidebarStatusDropTarget } from '../worktree-sidebar-drop-preview' +import { + NO_WORKTREE_SIDEBAR_DROP_TARGET, + type WorktreeSidebarLineageDropTarget +} from './worktree-row-drag-state' + +export function getPointerDropStatusTarget(args: { + container: HTMLElement + x: number + y: number +}): WorktreeSidebarLineageDropTarget { + const target = document.elementFromPoint(args.x, args.y) + if (!(target instanceof Element) || !args.container.contains(target)) { + return NO_WORKTREE_SIDEBAR_DROP_TARGET + } + const pinTarget = target.closest('[data-workspace-pin-drop-target]') + if (pinTarget && args.container.contains(pinTarget)) { + return { status: null, isPinDrop: true, lineageParentId: null } + } + const lineageParentId = getWorktreeLineageDropTargetId({ + container: args.container, + target, + pointerY: args.y + }) + const statusTarget = target.closest('[data-workspace-status-drop-target]') + return { + status: + statusTarget && args.container.contains(statusTarget) + ? ((statusTarget.dataset.workspaceStatus as WorkspaceStatus | undefined) ?? null) + : null, + isPinDrop: false, + lineageParentId + } +} + +export function shouldPreferSidebarStatusDropTarget(args: { + sourceGroupKey: string + target: WorktreeSidebarStatusDropTarget + workspaceStatuses: readonly WorkspaceStatusDefinition[] +}): boolean { + if (args.target.isPinDrop) { + return true + } + if (!args.target.status) { + return false + } + const sourceStatus = getWorkspaceStatusFromGroupKey(args.sourceGroupKey, args.workspaceStatuses) + // Why: overlapping edge zones — the section under the pointer must win so guide and drop agree. + return sourceStatus !== null && args.target.status !== sourceStatus +} diff --git a/src/renderer/src/components/sidebar/worktree-list/project-group-header-actions.tsx b/src/renderer/src/components/sidebar/worktree-list/project-group-header-actions.tsx new file mode 100644 index 00000000000..fc9edf19114 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/project-group-header-actions.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import { Ellipsis, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { getFolderWorkspacePathStatusDescription } from '@/lib/folder-workspace-path-status' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { FolderWorkspacePathStatus } from '../../../../../shared/folder-workspace-path-status' +import { REPO_HEADER_ACTION_BUTTON_CLASS } from '../repo-header-action-button-class' +import { + handleRepoHeaderActionPointerDown, + stopRepoHeaderKeyboardToggle, + stopRepoHeaderMenuEvent +} from './repo-header-event-guards' + +export function ProjectGroupHeaderMenu({ + groupId, + label, + onRename, + onDelete +}: { + groupId: string + label: string + onRename: (groupId: string, currentName: string) => void + onDelete: (groupId: string, groupName: string) => void +}): React.JSX.Element { + return ( + + + + + + onRename(groupId, label)}> + {translate('auto.components.sidebar.WorktreeList.4d7b73658c', 'Rename group')} + + onDelete(groupId, label)}> + {translate('auto.components.sidebar.WorktreeList.902115cdbe', 'Delete group')} + + + + ) +} + +export function ProjectGroupCreateWorkspaceButton({ + projectGroup, + label, + pathStatus, + disabled, + onCreate +}: { + projectGroup: ProjectGroup + label: string + pathStatus: FolderWorkspacePathStatus | null + disabled: boolean + onCreate: (projectGroup: ProjectGroup) => void +}): React.JSX.Element { + const createLabel = translate( + 'auto.components.sidebar.WorktreeList.bd37a57ac8', + 'Create workspace for {{value0}}', + { value0: label } + ) + return ( + + + + + + {pathStatus?.exists === false + ? getFolderWorkspacePathStatusDescription(pathStatus) + : createLabel} + + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/render-row-item-rows.ts b/src/renderer/src/components/sidebar/worktree-list/render-row-item-rows.ts new file mode 100644 index 00000000000..b0895ad6cd4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/render-row-item-rows.ts @@ -0,0 +1,13 @@ +import { PINNED_GROUP_KEY } from '../worktree-list-groups' +import type { HostSectionRow } from '../host-section-rows' + +export type WorktreeItemRow = Extract +export type FolderWorkspaceItemRow = Extract + +export function isWorktreeItemRow(row: HostSectionRow): row is WorktreeItemRow { + return row.type === 'item' +} + +export function isPinnedWorktreeRow(row: WorktreeItemRow): boolean { + return row.sectionKey === PINNED_GROUP_KEY +} diff --git a/src/renderer/src/components/sidebar/worktree-list/render-row-worktree-lookup.ts b/src/renderer/src/components/sidebar/worktree-list/render-row-worktree-lookup.ts new file mode 100644 index 00000000000..4605058d231 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/render-row-worktree-lookup.ts @@ -0,0 +1,79 @@ +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import type { RenderRow } from '../worktree-list-virtual-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import { isPinnedWorktreeRow, type WorktreeItemRow } from './render-row-item-rows' + +export function getRenderRowSidebarKey(row: RenderRow): string | null { + if (row.type === 'header') { + return row.key + } + if (row.type === 'item') { + return row.rowKey + } + if (row.type === 'folder-workspace') { + return folderWorkspaceKey(row.folderWorkspace.id) + } + if (row.type === 'pending-creation') { + return `pending:${row.creationId}` + } + if (row.type === 'imported-worktrees-card') { + return row.key + } + if (row.type === 'new-external-worktrees-inbox') { + return row.key + } + return null +} + +export function rowKeyMatchesRenderRow(row: RenderRow, rowKey: string): boolean { + if (row.type === 'lineage-group') { + return row.rows.some((item) => item.rowKey === rowKey) + } + return getRenderRowSidebarKey(row) === rowKey +} + +export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | null): boolean { + if (worktreeId === null) { + return false + } + if (row.type === 'folder-workspace') { + return folderWorkspaceKey(row.folderWorkspace.id) === worktreeId + } + if (row.type === 'lineage-group') { + return row.rows.some((item) => item.worktree.id === worktreeId) + } + return row.type === 'item' && row.worktree.id === worktreeId +} + +export function getRenderRowWorktreeItem( + row: RenderRow, + worktreeId: string +): WorktreeItemRow | null { + if (row.type === 'lineage-group') { + return row.rows.find((item) => item.worktree.id === worktreeId) ?? null + } + return row.type === 'item' && row.worktree.id === worktreeId ? row : null +} + +// Prefer the worktree's natural group row over its pinned duplicate when both are rendered. +export function findPreferredRenderRowIndexForWorktree( + renderRows: readonly RenderRow[], + worktreeId: string, + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +): number { + let fallbackIndex = -1 + for (let index = 0; index < renderRows.length; index++) { + const row = renderRows[index] + if (!renderRowContainsWorktree(row, worktreeId)) { + continue + } + if (fallbackIndex === -1) { + fallbackIndex = index + } + const itemRow = getRenderRowWorktreeItem(row, worktreeId) + if (pinnedDisplayPolicy === 'duplicate-in-groups' && itemRow && !isPinnedWorktreeRow(itemRow)) { + return index + } + } + return fallbackIndex +} diff --git a/src/renderer/src/components/sidebar/worktree-list/renderable-rows.ts b/src/renderer/src/components/sidebar/worktree-list/renderable-rows.ts new file mode 100644 index 00000000000..cc7c5e7870b --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/renderable-rows.ts @@ -0,0 +1,40 @@ +import { getLineageGroupKey } from '../worktree-list-groups' +import type { HostSectionRow } from '../host-section-rows' +import type { RenderRow } from '../worktree-list-virtual-rows' +import { isWorktreeItemRow, type WorktreeItemRow } from './render-row-item-rows' + +// Collapse a parent and its visible lineage descendants into one virtual row so the card renders them inline. +export function buildRenderableRows(rows: HostSectionRow[]): RenderRow[] { + const renderRows: RenderRow[] = [] + for (let index = 0; index < rows.length; index++) { + const row = rows[index] + if ( + !isWorktreeItemRow(row) || + row.lineageChildCount === 0 || + row.lineageCollapsed || + rows[index + 1]?.type !== 'item' || + (rows[index + 1] as WorktreeItemRow).depth <= row.depth + ) { + renderRows.push(row) + continue + } + + const groupRows: WorktreeItemRow[] = [row] + let cursor = index + 1 + while (cursor < rows.length) { + const child = rows[cursor] + if (!isWorktreeItemRow(child) || child.depth <= row.depth) { + break + } + groupRows.push(child) + cursor++ + } + renderRows.push({ + type: 'lineage-group', + key: `${row.sectionKey}:${getLineageGroupKey(row.worktree.id)}`, + rows: groupRows + }) + index = cursor - 1 + } + return renderRows +} diff --git a/src/renderer/src/components/sidebar/worktree-list/repo-header-event-guards.ts b/src/renderer/src/components/sidebar/worktree-list/repo-header-event-guards.ts new file mode 100644 index 00000000000..c4ba87c3f50 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/repo-header-event-guards.ts @@ -0,0 +1,31 @@ +import type React from 'react' +import { isRepoHeaderActionTarget } from '../project-header-drag' + +export function stopRepoHeaderKeyboardToggle(event: React.KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.stopPropagation() + } +} + +export function stopNestedWorktreeCardBubble(event: React.SyntheticEvent): void { + event.stopPropagation() +} + +export function handleRepoHeaderActionPointerDown(event: React.PointerEvent): void { + event.stopPropagation() +} + +export function handleRepoHeaderCollapseAffordancePointerDown( + event: React.PointerEvent +): void { + // Why: keep collapse-chevron clicks from arming the repo-header row drag. + event.stopPropagation() +} + +export function stopRepoHeaderMenuEvent(event: React.SyntheticEvent): void { + event.stopPropagation() +} + +export function shouldIgnoreRepoHeaderToggle(event: React.SyntheticEvent): boolean { + return isRepoHeaderActionTarget(event.target, event.currentTarget) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/repo-header-project-actions.tsx b/src/renderer/src/components/sidebar/worktree-list/repo-header-project-actions.tsx new file mode 100644 index 00000000000..c0a95f87c20 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/repo-header-project-actions.tsx @@ -0,0 +1,229 @@ +import React from 'react' +import { + CircleX, + Ellipsis, + Eye, + FolderInput, + FolderPlus, + Plus, + Shapes, + SlidersHorizontal, + Trash2 +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { WorktreeVisibilityDefaults } from '../../../../../shared/global-settings-types' +import { isGitRepoKind } from '../../../../../shared/repo-kind' +import { + REPO_HEADER_ACTION_BUTTON_CLASS, + REPO_HEADER_ACTION_REVEAL_CLASS +} from '../repo-header-action-button-class' +import type { getRepoHeaderCreateState } from '../repo-header-create-state' +import { + handleRepoHeaderActionPointerDown, + stopRepoHeaderKeyboardToggle, + stopRepoHeaderMenuEvent +} from './repo-header-event-guards' +import { getWorktreeVisibilityMenuLabel } from './worktree-visibility-menu-label' + +export type RepoHeaderProjectActions = { + getWorktreeVisibilityDefaults: (repo: Repo) => WorktreeVisibilityDefaults | undefined + onOpenRepoSettings: (projectId: string, sectionId?: string) => void + onOpenWorktreeVisibility: (repo: Repo) => void + onCreateGroupFromRepo: (repo: Repo) => void + onMoveProjectToGroup: (repo: Repo, groupId: string) => void + onRemoveProjectFromGroup: (repo: Repo) => void + onRemoveProject: (repo: Repo) => void + onCreateForRepo: (projectId: string) => void +} + +export function RepoHeaderProjectActionsMenu({ + repo, + label, + projectGroups, + actions +}: { + repo: Repo + label: string + projectGroups: readonly ProjectGroup[] + actions: RepoHeaderProjectActions +}): React.JSX.Element { + return ( + + + + + + + + + {translate('auto.components.sidebar.WorktreeList.2ef41bf9a7', 'Project actions')} + + + + actions.onOpenRepoSettings(repo.id)}> + + {translate('auto.components.sidebar.WorktreeList.2cdffbc728', 'Project Settings')} + + actions.onOpenRepoSettings(repo.id, getRepositoryIconSectionId(repo.id))} + > + + {translate('auto.components.sidebar.WorktreeList.e82d3589a1', 'Change Project Icon')} + + {isGitRepoKind(repo) ? ( + actions.onOpenWorktreeVisibility(repo)}> + + {getWorktreeVisibilityMenuLabel(repo, actions.getWorktreeVisibilityDefaults(repo))} + + ) : null} + actions.onCreateGroupFromRepo(repo)}> + + {translate('auto.components.sidebar.WorktreeList.cbfd565f83', 'New group from project')} + + {projectGroups.length > 0 ? ( + + + + {translate('auto.components.sidebar.WorktreeList.4a08fb55f2', 'Move to group')} + + + {projectGroups.map((group) => ( + actions.onMoveProjectToGroup(repo, group.id)} + > + {group.name} + + ))} + + + ) : null} + {repo.projectGroupId ? ( + actions.onRemoveProjectFromGroup(repo)}> + + {translate('auto.components.sidebar.WorktreeList.64e55f7f01', 'Remove from group')} + + ) : null} + + actions.onRemoveProject(repo)}> + + {translate('auto.components.sidebar.WorktreeList.c83968f87f', 'Remove Project')} + + + + ) +} + +export function RepoHeaderCreateWorkspaceButton({ + repo, + label, + createState, + onCreateForRepo +}: { + repo: Repo + label: string + createState: ReturnType | null + onCreateForRepo: (projectId: string) => void +}): React.JSX.Element { + const fallbackLabel = translate( + 'auto.components.sidebar.WorktreeList.bb85cd86ba', + 'Create workspace for {{value0}}', + { value0: label } + ) + return ( + + + {createState?.disabled ? ( + event.stopPropagation()} + onPointerDown={handleRepoHeaderActionPointerDown} + > + + + ) : ( + + )} + + + {createState?.tooltip ?? fallbackLabel} + + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/sidebar-editable-target.ts b/src/renderer/src/components/sidebar/worktree-list/sidebar-editable-target.ts new file mode 100644 index 00000000000..390df9dae2f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/sidebar-editable-target.ts @@ -0,0 +1,19 @@ +export function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + + // xterm's hidden input textarea isn't a real text field; treating it as one would block sidebar shortcuts. + if (target.classList.contains('xterm-helper-textarea')) { + return false + } + + if (target.isContentEditable) { + return true + } + + return ( + target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== + null + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/sidebar-row-reveal-ancestors.ts b/src/renderer/src/components/sidebar/worktree-list/sidebar-row-reveal-ancestors.ts new file mode 100644 index 00000000000..cc387daba32 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/sidebar-row-reveal-ancestors.ts @@ -0,0 +1,112 @@ +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import { + getProjectGroupHeaderKey, + PINNED_GROUP_KEY, + type ProjectGroupingModel +} from '../worktree-list-groups' + +function getProjectIdFromHeaderRowKey(rowKey: string): string | null { + if (!rowKey.startsWith('project:')) { + return null + } + const withoutPrefix = rowKey.slice('project:'.length) + const setupSeparator = withoutPrefix.indexOf('::setup:') + return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) +} + +function getRepoIdsFromHeaderRowKey( + rowKey: string, + repoMap: Map, + projectGrouping?: ProjectGroupingModel +): string[] { + if (rowKey.startsWith('repo:')) { + return [rowKey.slice('repo:'.length)] + } + const setupMarker = '::setup:' + const setupIndex = rowKey.indexOf(setupMarker) + if (rowKey.startsWith('project:') && setupIndex !== -1) { + return [rowKey.slice(setupIndex + setupMarker.length)] + } + const projectId = getProjectIdFromHeaderRowKey(rowKey) + if (!projectId) { + return [] + } + const repoIds = new Set() + for (const setup of projectGrouping?.projectHostSetups ?? []) { + if (setup.projectId === projectId && repoMap.has(setup.repoId)) { + repoIds.add(setup.repoId) + } + } + const project = projectGrouping?.projects.find((candidate) => candidate.id === projectId) + for (const repoId of project?.sourceRepoIds ?? []) { + if (repoMap.has(repoId)) { + repoIds.add(repoId) + } + } + return [...repoIds] +} + +function getProjectGroupAncestorKeys( + projectGroupId: string | null | undefined, + projectGroups: readonly ProjectGroup[] +): string[] { + const groupsById = new Map(projectGroups.map((group) => [group.id, group])) + const keys: string[] = [] + const seen = new Set() + let currentGroupId = projectGroupId ?? null + while (currentGroupId && !seen.has(currentGroupId)) { + const group = groupsById.get(currentGroupId) + if (!group) { + break + } + seen.add(currentGroupId) + keys.unshift(getProjectGroupHeaderKey(group.id)) + currentGroupId = group.parentGroupId + } + return keys +} + +export function getSidebarRowRevealAncestorKeys(args: { + rowKey: string + repoMap: Map + projectGroups: readonly ProjectGroup[] + projectGrouping?: ProjectGroupingModel +}): string[] { + if (args.rowKey.startsWith('project-group:')) { + const groupId = args.rowKey.slice('project-group:'.length) + const group = args.projectGroups.find((candidate) => candidate.id === groupId) + return getProjectGroupAncestorKeys(group?.parentGroupId, args.projectGroups) + } + const keys = new Set() + for (const repoId of getRepoIdsFromHeaderRowKey( + args.rowKey, + args.repoMap, + args.projectGrouping + )) { + const repo = args.repoMap.get(repoId) + for (const key of getProjectGroupAncestorKeys(repo?.projectGroupId, args.projectGroups)) { + keys.add(key) + } + } + return [...keys] +} + +export function getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree, + collapsedGroups +}: { + worktree: Worktree + collapsedGroups: ReadonlySet +}): string[] { + if (!worktree.isPinned) { + return [] + } + const keys: string[] = [] + // Why: the reveal effect already opens this host; re-returning it would toggle it back closed. + if (collapsedGroups.has(PINNED_GROUP_KEY)) { + keys.push(PINNED_GROUP_KEY) + } + return keys +} diff --git a/src/renderer/src/components/sidebar/worktree-list/stable-empty-store-slices.ts b/src/renderer/src/components/sidebar/worktree-list/stable-empty-store-slices.ts new file mode 100644 index 00000000000..49cae96a88b --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/stable-empty-store-slices.ts @@ -0,0 +1,9 @@ +import type { AppState } from '@/store/types' + +// Why: selectors that opt out of a slice must return one stable reference, or every store tick looks like a change. +export const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} +export const EMPTY_WORKTREE_ID_SET: ReadonlySet = new Set() +export const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} +export const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} +export const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} +export const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts b/src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts new file mode 100644 index 00000000000..61269d78225 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts @@ -0,0 +1,63 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' +import { + EMPTY_AGENT_STATUS_BY_PANE_KEY, + EMPTY_PTY_IDS_BY_TAB_ID, + EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID, + EMPTY_TABS_BY_WORKTREE, + EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID +} from './stable-empty-store-slices' + +// The agent send picker forces its target workspace visible, but only while a running +// agent there can actually receive the message. +export function useAgentSendTargetWorktreeId(): string | null { + const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) + // Why: eligibility only matters while the picker is open; when closed, don't subscribe to wake-time layout churn. + const agentTargetStatusByPaneKey = useAppStore((s) => + agentSendPopoverTargetMode ? s.agentStatusByPaneKey : EMPTY_AGENT_STATUS_BY_PANE_KEY + ) + const agentTargetStatusEpoch = useAppStore((s) => + agentSendPopoverTargetMode ? s.agentStatusEpoch : 0 + ) + const agentTargetTabsByWorktree = useAppStore((s) => + agentSendPopoverTargetMode ? s.tabsByWorktree : EMPTY_TABS_BY_WORKTREE + ) + const agentTargetTerminalLayoutsByTabId = useAppStore((s) => + agentSendPopoverTargetMode ? s.terminalLayoutsByTabId : EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID + ) + const agentTargetPtyIdsByTabId = useAppStore((s) => + agentSendPopoverTargetMode ? s.ptyIdsByTabId : EMPTY_PTY_IDS_BY_TAB_ID + ) + const agentTargetRuntimePaneTitlesByTabId = useAppStore((s) => + agentSendPopoverTargetMode ? s.runtimePaneTitlesByTabId : EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID + ) + return useMemo(() => { + void agentTargetStatusEpoch + if (!agentSendPopoverTargetMode) { + return null + } + const targets = deriveRunningAgentSendTargets( + { + agentStatusByPaneKey: agentTargetStatusByPaneKey, + tabsByWorktree: agentTargetTabsByWorktree, + terminalLayoutsByTabId: agentTargetTerminalLayoutsByTabId, + ptyIdsByTabId: agentTargetPtyIdsByTabId, + runtimePaneTitlesByTabId: agentTargetRuntimePaneTitlesByTabId + }, + agentSendPopoverTargetMode.worktreeId + ) + return targets.some((target) => target.status === 'eligible') + ? agentSendPopoverTargetMode.worktreeId + : null + }, [ + // Why: eligibility can flip when the stale-boundary scheduler bumps this epoch without replacing the status map. + agentTargetStatusEpoch, + agentSendPopoverTargetMode, + agentTargetStatusByPaneKey, + agentTargetTabsByWorktree, + agentTargetTerminalLayoutsByTabId, + agentTargetPtyIdsByTabId, + agentTargetRuntimePaneTitlesByTabId + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts b/src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts new file mode 100644 index 00000000000..681056b5fe6 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts @@ -0,0 +1,90 @@ +import { useMemo } from 'react' +import type { AppState } from '@/store/types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types' +import { + getGroupKeysForWorktree, + getLineageGroupKey, + PINNED_GROUP_KEY, + type ProjectGroupingModel, + type WorktreeGroupBy +} from '../worktree-list-groups' +import { getWorktreeLineageAncestors } from '../worktree-lineage-projection' + +// While the agent send picker targets a workspace, force open every section that hides it. +export function useEffectiveCollapsedGroups(args: { + collapsedGroups: Set + agentSendTargetWorktreeId: string | null + groupBy: WorktreeGroupBy + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + prCache: AppState['prCache'] | null + workspaceStatuses: readonly WorkspaceStatusDefinition[] + settings: AppState['settings'] + projectGroups: readonly ProjectGroup[] + projectGrouping: ProjectGroupingModel +}): Set { + const { + collapsedGroups, + agentSendTargetWorktreeId, + groupBy, + repoMap, + worktreeMap, + worktreeLineageById, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + } = args + return useMemo(() => { + if (!agentSendTargetWorktreeId) { + return collapsedGroups + } + const targetWorktree = worktreeMap.get(agentSendTargetWorktreeId) + if (!targetWorktree) { + return collapsedGroups + } + const next = new Set(collapsedGroups) + if (targetWorktree.isPinned) { + next.delete(PINNED_GROUP_KEY) + } else { + for (const groupKey of getGroupKeysForWorktree( + groupBy, + targetWorktree, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + )) { + next.delete(groupKey) + } + } + + for (const parent of getWorktreeLineageAncestors( + targetWorktree, + worktreeLineageById, + worktreeMap + )) { + next.delete(getLineageGroupKey(parent.id)) + } + return next + }, [ + agentSendTargetWorktreeId, + collapsedGroups, + groupBy, + prCache, + projectGroups, + projectGrouping, + repoMap, + settings, + workspaceStatuses, + worktreeLineageById, + worktreeMap + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-folder-workspace-path-status-rows.ts b/src/renderer/src/components/sidebar/worktree-list/use-folder-workspace-path-status-rows.ts new file mode 100644 index 00000000000..8e5bd17b81d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-folder-workspace-path-status-rows.ts @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry' +import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { AppState } from '@/store/types' +import { getFolderPathStatusRouteOptionsForRows } from '../worktree-list-host-filtering' + +type FolderPathStatusRequest = Parameters[0] + +// Keeps the sidebar's folder-path probes fresh for every project group and folder workspace +// it renders, and hands rows a cache reader that ignores expired negative results. +export function useFolderWorkspacePathStatusRows(args: { + allRepoIds: string[] + repoMap: Map + projectGroups: readonly ProjectGroup[] + folderWorkspaces: readonly FolderWorkspace[] + sshConnectionStates: AppState['sshConnectionStates'] +}) { + const { allRepoIds, repoMap, projectGroups, folderWorkspaces, sshConnectionStates } = args + const { + folderWorkspacePathStatuses, + fetchFolderWorkspacePathStatus, + getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus, + activeRuntimeEnvironmentId + } = useAppStore( + useShallow((s) => ({ + folderWorkspacePathStatuses: s.folderWorkspacePathStatuses, + fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus, + getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus, + activeRuntimeEnvironmentId: s.settings?.activeRuntimeEnvironmentId ?? null + })) + ) + const folderPathStatusRepoMembershipKey = useMemo( + () => + allRepoIds + .map((repoId) => { + const repo = repoMap.get(repoId) + return `${repoId}:${repo?.path ?? ''}:${repo?.projectGroupId ?? ''}:${repo?.connectionId ?? ''}` + }) + .join('\0'), + [allRepoIds, repoMap] + ) + const folderPathStatusSshConnectionKey = useMemo( + () => + [...sshConnectionStates.entries()] + .map(([connectionId, state]) => `${connectionId}:${state.status}`) + .sort() + .join('\0'), + [sshConnectionStates] + ) + const folderPathStatusCacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick( + folderWorkspacePathStatuses + ) + const projectGroupByIdForFolderPathStatus = useMemo( + () => new Map(projectGroups.map((group) => [group.id, group])), + [projectGroups] + ) + const folderWorkspaceByIdForFolderPathStatus = useMemo( + () => new Map(folderWorkspaces.map((workspace) => [workspace.id, workspace])), + [folderWorkspaces] + ) + const getFolderPathStatusRouteOptions = useCallback( + (request: FolderPathStatusRequest) => + getFolderPathStatusRouteOptionsForRows({ + request, + projectGroupsById: projectGroupByIdForFolderPathStatus, + folderWorkspacesById: folderWorkspaceByIdForFolderPathStatus + }), + [folderWorkspaceByIdForFolderPathStatus, projectGroupByIdForFolderPathStatus] + ) + useEffect(() => { + const requests = new Map< + string, + { + request: FolderPathStatusRequest + options?: { runtimeEnvironmentId: string | null } + } + >() + for (const group of projectGroups) { + if (group.parentPath) { + const request = { scope: 'project-group' as const, projectGroupId: group.id } + const options = getFolderPathStatusRouteOptions(request) + requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) + } + } + for (const workspace of folderWorkspaces) { + const request = { scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id } + const options = getFolderPathStatusRouteOptions(request) + requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) + } + for (const { request, options } of requests.values()) { + void fetchFolderWorkspacePathStatus(request, { force: true, ...options }) + } + }, [ + activeRuntimeEnvironmentId, + fetchFolderWorkspacePathStatus, + folderPathStatusRepoMembershipKey, + folderPathStatusSshConnectionKey, + folderWorkspaces, + getFolderPathStatusRouteOptions, + getFolderWorkspacePathStatusCacheKey, + projectGroups + ]) + const getCachedFolderWorkspacePathStatus = useCallback( + (request: FolderPathStatusRequest) => { + const options = getFolderPathStatusRouteOptions(request) + const cacheKey = getFolderWorkspacePathStatusCacheKey(request, options) + // Why: don't let an expired negative status keep folder workspaces disabled while a refresh is in flight. + void folderWorkspacePathStatuses[cacheKey] + void folderPathStatusCacheExpiryTick + return getFreshFolderWorkspacePathStatus(request, options) + }, + [ + folderWorkspacePathStatuses, + folderPathStatusCacheExpiryTick, + getFolderPathStatusRouteOptions, + getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus + ] + ) + + return getCachedFolderWorkspacePathStatus +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-group-toggle-with-scroll-anchor.ts b/src/renderer/src/components/sidebar/worktree-list/use-group-toggle-with-scroll-anchor.ts new file mode 100644 index 00000000000..044c2a2651f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-group-toggle-with-scroll-anchor.ts @@ -0,0 +1,30 @@ +import { useCallback, useMemo } from 'react' +import type React from 'react' +import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' +import { createLineageToggleHandlerCache } from '../worktree-lineage-toggle-handler-cache' + +// Collapsing a section changes total height, so snapshot the anchor first or the viewport jumps. +export function useGroupToggleWithScrollAnchor(args: { + scrollRef: React.RefObject + toggleGroup: (key: string) => void +}) { + const { scrollRef, toggleGroup } = args + const recordCurrentScrollAnchor = useCallback(() => { + scrollRef.current?.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)) + }, [scrollRef]) + const toggleGroupWithScrollAnchor = useCallback( + (groupKey: string) => { + recordCurrentScrollAnchor() + toggleGroup(groupKey) + }, + [recordCurrentScrollAnchor, toggleGroup] + ) + // Why: memo'd WorktreeCard needs a per-group-key stable onLineageToggle + // identity to bail out of re-renders; see worktree-lineage-toggle-handler-cache. + const getLineageToggleHandler = useMemo( + () => createLineageToggleHandlerCache(toggleGroupWithScrollAnchor), + [toggleGroupWithScrollAnchor] + ) + + return { toggleGroupWithScrollAnchor, getLineageToggleHandler } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts b/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts new file mode 100644 index 00000000000..26cd2b352db --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts @@ -0,0 +1,284 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { getRenderRowOptionId } from './active-descendant-option' +import { + findPreferredRenderRowIndexForWorktree, + getRenderRowSidebarKey, + rowKeyMatchesRenderRow +} from './render-row-worktree-lookup' +import { revealMountedSidebarRowElement, revealMountedWorktreeElement } from './mounted-row-reveal' +import { getSidebarRowRevealAncestorKeys } from './sidebar-row-reveal-ancestors' +import { sidebarWorkspaceStillExists } from '../worktree-list-folder-reveal' +import { resolvePendingSidebarReveal } from './worktree-sidebar-scroll-tuning' +import { + expandGroupsForWorktreeReveal, + MAX_REVEAL_RETRIES, + type PendingSidebarRevealArgs +} from './pending-sidebar-reveal-inputs' + +// Drives the store's two pending reveal requests (a worktree card, or any sidebar row) +// from "expand the ancestors" through "scroll the mounted element into view". +export function usePendingSidebarReveal(args: PendingSidebarRevealArgs): void { + const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) + const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) + const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null) + const pendingRowRevealRetryRef = useRef<{ rowKey: string; count: number } | null>(null) + const argsRef = useRef(args) + argsRef.current = args + + const { + pendingRevealWorktree, + pendingRevealSidebarRow, + clearPendingRevealWorktreeId, + clearPendingRevealSidebarRow, + renderRows, + virtualizer, + schedulePendingRevealFrame, + cancelPendingRevealFrames, + flashRevealedRow, + markRevealScroll, + pinnedDisplayPolicy + } = args + + const scheduleRetryTick = useCallback( + (cancelled: () => boolean) => { + schedulePendingRevealFrame(() => { + if (!cancelled()) { + setPendingRevealRetryTick((tick) => tick + 1) + } + }) + }, + [schedulePendingRevealFrame] + ) + + useEffect(() => { + if (!pendingRevealWorktree) { + return + } + const current = argsRef.current + if (current.agentSendTargetWorktreeId !== pendingRevealWorktree.worktreeId) { + expandGroupsForWorktreeReveal(current, pendingRevealWorktree.worktreeId) + } + + let cancelled = false + schedulePendingRevealFrame(() => { + if (cancelled) { + return + } + const targetWorktreeStillExists = sidebarWorkspaceStillExists( + pendingRevealWorktree.worktreeId, + argsRef.current.worktrees, + argsRef.current.folderWorkspaces + ) + const targetIndex = findPreferredRenderRowIndexForWorktree( + renderRows, + pendingRevealWorktree.worktreeId, + pinnedDisplayPolicy + ) + const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists }) + if (outcome === 'clear') { + pendingRevealRetryRef.current = null + clearPendingRevealWorktreeId() + return + } + if (outcome !== 'scroll-and-clear') { + return + } + const targetRow = renderRows[targetIndex] + const container = argsRef.current.scrollRef.current + const revealedOption = container + ? revealMountedWorktreeElement( + container, + pendingRevealWorktree.worktreeId, + pendingRevealWorktree.behavior, + getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId), + markRevealScroll + ) + : null + if (revealedOption) { + if (pendingRevealWorktree.highlight) { + const revealedRowKey = + revealedOption.dataset.worktreeRowKey ?? getRenderRowSidebarKey(targetRow) + if (revealedRowKey) { + flashRevealedRow(revealedRowKey) + } + } + if (pendingRevealWorktree.beginRename) { + setRenamingWorktreeId({ + worktreeId: pendingRevealWorktree.worktreeId, + rowKey: revealedOption.dataset.worktreeRowKey + }) + } + pendingRevealRetryRef.current = null + clearPendingRevealWorktreeId() + return + } + + // Why: virtual indexing can leave the card edge clipped; stage it into the window, then retry the exact DOM reveal. + virtualizer.scrollToIndex(targetIndex, { align: 'auto', behavior: 'auto' }) + const previousRetry = pendingRevealRetryRef.current + const nextRetryCount = + previousRetry?.worktreeId === pendingRevealWorktree.worktreeId ? previousRetry.count + 1 : 1 + pendingRevealRetryRef.current = { + worktreeId: pendingRevealWorktree.worktreeId, + count: nextRetryCount + } + if (nextRetryCount <= MAX_REVEAL_RETRIES) { + scheduleRetryTick(() => cancelled) + return + } + pendingRevealRetryRef.current = null + clearPendingRevealWorktreeId() + }) + return () => { + cancelled = true + cancelPendingRevealFrames() + } + }, [ + pendingRevealWorktree, + args.agentSendTargetWorktreeId, + args.groupBy, + args.worktrees, + args.folderWorkspaces, + args.repoMap, + args.prCache, + args.worktreeLineageById, + args.worktreeMap, + renderRows, + virtualizer, + clearPendingRevealWorktreeId, + args.toggleGroup, + args.collapsedGroups, + args.defaultHostId, + args.workspaceStatuses, + args.settings, + pinnedDisplayPolicy, + args.projectGrouping, + args.projectGroups, + pendingRevealRetryTick, + flashRevealedRow, + markRevealScroll, + setRenamingWorktreeId, + schedulePendingRevealFrame, + cancelPendingRevealFrames, + scheduleRetryTick + ]) + + useEffect(() => { + if (!pendingRevealSidebarRow) { + return + } + const current = argsRef.current + + const isProjectHeaderTarget = + pendingRevealSidebarRow.rowKey.startsWith('project-group:') || + pendingRevealSidebarRow.rowKey.startsWith('project:') || + pendingRevealSidebarRow.rowKey.startsWith('repo:') + if (isProjectHeaderTarget && current.groupBy !== 'repo') { + return + } + + let toggledAncestor = false + for (const groupKey of getSidebarRowRevealAncestorKeys({ + rowKey: pendingRevealSidebarRow.rowKey, + repoMap: current.repoMap, + projectGroups: current.projectGroups, + projectGrouping: current.projectGrouping + })) { + if (current.collapsedGroups.has(groupKey)) { + current.toggleGroup(groupKey) + toggledAncestor = true + } + } + if (toggledAncestor) { + return + } + + let cancelled = false + const retryPendingReveal = (): boolean => { + const previousRetry = pendingRowRevealRetryRef.current + const nextRetryCount = + previousRetry?.rowKey === pendingRevealSidebarRow.rowKey ? previousRetry.count + 1 : 1 + pendingRowRevealRetryRef.current = { + rowKey: pendingRevealSidebarRow.rowKey, + count: nextRetryCount + } + if (nextRetryCount <= MAX_REVEAL_RETRIES) { + scheduleRetryTick(() => cancelled) + return true + } + return false + } + schedulePendingRevealFrame(() => { + if (cancelled) { + return + } + const targetIndex = renderRows.findIndex((row) => + rowKeyMatchesRenderRow(row, pendingRevealSidebarRow.rowKey) + ) + if (targetIndex === -1) { + if (retryPendingReveal()) { + return + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.sidebarRowMissing', + 'Target no longer exists' + ) + ) + return + } + + const container = argsRef.current.scrollRef.current + const revealedElement = container + ? revealMountedSidebarRowElement( + container, + pendingRevealSidebarRow.rowKey, + pendingRevealSidebarRow.behavior, + markRevealScroll + ) + : null + if (revealedElement) { + if (pendingRevealSidebarRow.highlight) { + flashRevealedRow(pendingRevealSidebarRow.rowKey) + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + return + } + + virtualizer.scrollToIndex(targetIndex, { align: 'auto', behavior: 'auto' }) + if (retryPendingReveal()) { + return + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + }) + + return () => { + cancelled = true + cancelPendingRevealFrames() + } + }, [ + pendingRevealSidebarRow, + args.repoMap, + args.projectGroups, + args.projectGrouping, + args.collapsedGroups, + args.groupBy, + args.toggleGroup, + renderRows, + virtualizer, + pendingRevealRetryTick, + flashRevealedRow, + markRevealScroll, + clearPendingRevealSidebarRow, + schedulePendingRevealFrame, + cancelPendingRevealFrames, + scheduleRetryTick + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts b/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts new file mode 100644 index 00000000000..e3e30642c8e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts @@ -0,0 +1,70 @@ +import { useCallback, useLayoutEffect, useState } from 'react' +import type { ActiveSurfaceVariant } from '../WorktreeCard' +import type { HostSectionRow } from '../host-section-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import { isPinnedWorktreeRow, type WorktreeItemRow } from './render-row-item-rows' + +// A worktree can render in more than one section; the row the user actually clicked owns +// the primary active surface so its duplicates stay visually secondary. +export function usePrimaryActiveWorktreeRow(args: { + rows: HostSectionRow[] + activeWorktreeId: string | null + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + onImmediateWorktreeActivate: (worktreeId: string, rowKey: string | undefined) => void +}) { + const { rows, activeWorktreeId, pinnedDisplayPolicy, onImmediateWorktreeActivate } = args + const [primaryActiveWorktreeRow, setPrimaryActiveWorktreeRow] = useState<{ + worktreeId: string + rowKey: string + } | null>(null) + + useLayoutEffect(() => { + if (activeWorktreeId === null) { + setPrimaryActiveWorktreeRow(null) + return + } + setPrimaryActiveWorktreeRow((current) => { + if (current === null || current.worktreeId !== activeWorktreeId) { + return null + } + const rowStillVisible = rows.some( + (row) => + row.type === 'item' && + row.worktree.id === current.worktreeId && + row.rowKey === current.rowKey + ) + return rowStillVisible ? current : null + }) + }, [activeWorktreeId, rows]) + + const getActiveSurfaceVariant = useCallback( + (row: WorktreeItemRow): ActiveSurfaceVariant => { + if (primaryActiveWorktreeRow?.worktreeId === row.worktree.id) { + return primaryActiveWorktreeRow.rowKey === row.rowKey ? 'primary' : 'secondary' + } + if ( + pinnedDisplayPolicy === 'duplicate-in-groups' && + activeWorktreeId === row.worktree.id && + isPinnedWorktreeRow(row) + ) { + return 'secondary' + } + return 'primary' + }, + [activeWorktreeId, pinnedDisplayPolicy, primaryActiveWorktreeRow] + ) + + const handleImmediateWorktreeRowActivate = useCallback( + (worktreeId: string, rowKey: string | undefined): void => { + setPrimaryActiveWorktreeRow(rowKey ? { worktreeId, rowKey } : null) + onImmediateWorktreeActivate(worktreeId, rowKey) + }, + [onImmediateWorktreeActivate] + ) + + return { + primaryActiveWorktreeRow, + getActiveSurfaceVariant, + handleImmediateWorktreeRowActivate + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts b/src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts new file mode 100644 index 00000000000..800852f9570 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts @@ -0,0 +1,172 @@ +import { useCallback, useMemo, useState } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' + +export type ProjectGroupNameDialogState = + | { type: 'create-from-repo'; repo: Repo } + | { type: 'rename'; groupId: string; currentName: string } + +export type ProjectGroupDeleteDialogState = { + groupId: string + groupName: string + removeContainedProjects: boolean +} + +export type ProjectGroupDialogs = ReturnType + +function reportProjectGroupDeleteFailures(result: { + status: string + failedProjectRemovals: readonly unknown[] + requestedProjectIds: readonly unknown[] +}): void { + // Why: a missing group is already the desired end state, so only a real delete failure warrants a toast. + if (result.status === 'group-delete-failed') { + toast.error( + translate('auto.components.sidebar.WorktreeList.groupDeleteFailed', 'Failed to delete group'), + { + description: translate( + 'auto.components.sidebar.WorktreeList.groupDeleteFailedDesc', + 'Something went wrong while deleting the group. No projects were removed.' + ) + } + ) + return + } + if (result.status === 'deleted-group' && result.failedProjectRemovals.length > 0) { + const requestedCount = result.requestedProjectIds.length + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.b667b59632', + 'Some projects could not be removed from Orca' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeList.f94466bc39', + '{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.', + { + value0: result.failedProjectRemovals.length, + value1: requestedCount, + value2: requestedCount === 1 ? '' : 's' + } + ) + } + ) + } +} + +// Create/rename/delete flows for project groups, including the contained-project fan-out. +export function useProjectGroupDialogs(args: { + repos: readonly Repo[] + repoMap: Map + projectGroups: readonly ProjectGroup[] +}) { + const { repos, repoMap, projectGroups } = args + const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) + const createProjectGroup = useAppStore((s) => s.createProjectGroup) + const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) + const deleteProjectGroupWithContainedProjects = useAppStore( + (s) => s.deleteProjectGroupWithContainedProjects + ) + const [nameDialog, setNameDialog] = useState(null) + const [deleteDialog, setDeleteDialog] = useState(null) + + const handleCreateGroupFromRepo = useCallback((repo: Repo) => { + setNameDialog({ type: 'create-from-repo', repo }) + }, []) + + const handleMoveProjectToGroup = useCallback( + (repo: Repo, groupId: string) => { + if (repo.projectGroupId === groupId) { + return + } + void moveProjectToGroup(repo.id, groupId) + }, + [moveProjectToGroup] + ) + + const handleRemoveProjectFromGroup = useCallback( + (repo: Repo) => { + void moveProjectToGroup(repo.id, null) + }, + [moveProjectToGroup] + ) + + const handleRenameProjectGroup = useCallback((groupId: string, currentName: string) => { + setNameDialog({ type: 'rename', groupId, currentName }) + }, []) + + const handleSubmitProjectGroupName = useCallback( + async (name: string) => { + if (!nameDialog) { + return + } + if (nameDialog.type === 'create-from-repo') { + const group = await createProjectGroup(name) + if (group) { + await moveProjectToGroup(nameDialog.repo.id, group.id) + } + return + } + await updateProjectGroup(nameDialog.groupId, { name }) + }, + [createProjectGroup, moveProjectToGroup, nameDialog, updateProjectGroup] + ) + + const deleteTargets = useMemo(() => { + if (!deleteDialog) { + return null + } + return selectProjectGroupRemovalTargets(projectGroups, repos, deleteDialog.groupId) + }, [deleteDialog, projectGroups, repos]) + const deleteProjectCount = deleteTargets?.projectIds.length ?? 0 + const deleteProjectNames = useMemo( + () => + (deleteTargets?.projectIds ?? []).map( + (projectId) => repoMap.get(projectId)?.displayName ?? projectId + ), + [deleteTargets, repoMap] + ) + const removeContainedProjects = + deleteProjectCount > 0 && deleteDialog?.removeContainedProjects === true + + const handleDeleteProjectGroup = useCallback((groupId: string, groupName: string) => { + setDeleteDialog({ groupId, groupName, removeContainedProjects: false }) + }, []) + + const handleConfirmDeleteProjectGroup = useCallback(async () => { + if (!deleteDialog) { + return + } + try { + reportProjectGroupDeleteFailures( + await deleteProjectGroupWithContainedProjects(deleteDialog.groupId, { + removeContainedProjects + }) + ) + } finally { + // Why: deleting contained projects can unmount this dialog before its close handler runs, so the parent owns cleanup. + setDeleteDialog(null) + } + }, [deleteProjectGroupWithContainedProjects, removeContainedProjects, deleteDialog]) + + return { + nameDialog, + setNameDialog, + deleteDialog, + setDeleteDialog, + deleteProjectCount, + deleteProjectNames, + removeContainedProjects, + handleCreateGroupFromRepo, + handleMoveProjectToGroup, + handleRemoveProjectFromGroup, + handleRenameProjectGroup, + handleSubmitProjectGroupName, + handleDeleteProjectGroup, + handleConfirmDeleteProjectGroup + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts b/src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts new file mode 100644 index 00000000000..892f2d7694a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts @@ -0,0 +1,10 @@ +import { useRef } from 'react' +import { reuseArrayIfEqual } from '../worktree-agent-row-selectors' + +// Why: epoch-driven recomputes often produce arrays whose contents and order are unchanged; reusing the previous identity when element-wise equal keeps downstream memos and React.memo'd cards bailing out. Safe only because elements (Worktree objects / id strings) are immutably REPLACED on change — never wrap arrays of mutated-in-place objects. +export function useReusedArrayIdentity(next: T[]): T[] { + const previousRef = useRef(next) + const result = reuseArrayIfEqual(previousRef.current, next) + previousRef.current = result + return result +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts new file mode 100644 index 00000000000..1083b5e4084 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts @@ -0,0 +1,206 @@ +import { useCallback, useMemo, useState } from 'react' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { Repo } from '../../../../../shared/repo-types' +import { + IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR, + keepImportedWorktreesHiddenCard, + showImportedWorktreesCard, + type ImportedWorktreeCardActionState +} from '../imported-worktrees-card-actions' +import { + buildImportedWorktreesCardCandidates, + getHiddenImportedWorktrees +} from '../imported-worktrees-card-candidates' +import { + suppressNewExternalWorktreeInbox, + type NewExternalWorktreesInboxActionState +} from '../new-external-worktrees-inbox-actions' +import { buildNewExternalWorktreesInboxCandidates } from '../new-external-worktrees-inbox-candidates' + +// The two sidebar notice rows for worktrees Orca detected but does not yet show, plus the +// pending/error state their inline actions surface. +export function useSidebarExternalWorktreeCards(args: { + repos: readonly Repo[] + visibleReposForRows: readonly Repo[] + detectedWorktreesByRepo: AppState['detectedWorktreesByRepo'] + filterRepoIds: readonly string[] +}) { + const { repos, visibleReposForRows, detectedWorktreesByRepo, filterRepoIds } = args + const updateRepo = useAppStore((s) => s.updateRepo) + const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const settings = useAppStore((s) => s.settings) + const visibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost) + const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState< + Map + >(new Map()) + const [newExternalWorktreeInboxActionState, setNewExternalWorktreeInboxActionState] = useState< + Map + >(new Map()) + const [suppressExternalWorktreeInboxRepoId, setSuppressExternalWorktreeInboxRepoId] = useState< + string | null + >(null) + + const importedWorktreesByRepo = useMemo(() => { + const forceVisibleRepoIds = new Set( + [...importedWorktreeCardActionState.entries()] + .filter(([, state]) => state.forceVisible) + .map(([repoId]) => repoId) + ) + return buildImportedWorktreesCardCandidates({ + repos: visibleReposForRows, + detectedWorktreesByRepo, + filterRepoIds, + forceVisibleRepoIds, + settings, + visibilityDefaultsByHost + }) + }, [ + detectedWorktreesByRepo, + filterRepoIds, + importedWorktreeCardActionState, + settings, + visibilityDefaultsByHost, + visibleReposForRows + ]) + const newExternalWorktreesInboxByRepo = useMemo( + () => + buildNewExternalWorktreesInboxCandidates({ + repos: visibleReposForRows, + detectedWorktreesByRepo, + filterRepoIds, + settings, + visibilityDefaultsByHost + }), + [ + detectedWorktreesByRepo, + filterRepoIds, + settings, + visibilityDefaultsByHost, + visibleReposForRows + ] + ) + + const setImportedWorktreeCardState = useCallback( + (projectId: string, state: ImportedWorktreeCardActionState | null) => { + setImportedWorktreeCardActionState((previous) => { + const next = new Map(previous) + if (state) { + next.set(projectId, state) + } else { + next.delete(projectId) + } + return next + }) + }, + [] + ) + + const handleShowImportedWorktrees = useCallback( + async (projectId: string) => { + await showImportedWorktreesCard({ + projectId, + forceVisible: importedWorktreeCardActionState.get(projectId)?.forceVisible === true, + updateRepo, + fetchWorktrees, + setCardState: setImportedWorktreeCardState + }) + }, + [fetchWorktrees, importedWorktreeCardActionState, setImportedWorktreeCardState, updateRepo] + ) + + const handleKeepImportedWorktreesHidden = useCallback( + async (projectId: string) => { + const repo = repos.find((candidate) => candidate.id === projectId) + let detected = detectedWorktreesByRepo[projectId] + // Why: baseline seeding needs authoritative hidden paths, so don't dismiss on a stale snapshot. + if (detected?.authoritative !== true) { + const refreshed = await fetchWorktrees(projectId, { requireAuthoritative: true }) + if (!refreshed) { + setImportedWorktreeCardState(projectId, { + pending: false, + error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR + }) + return + } + detected = useAppStore.getState().detectedWorktreesByRepo[projectId] + } + if (detected?.authoritative !== true) { + setImportedWorktreeCardState(projectId, { + pending: false, + error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR + }) + return + } + await keepImportedWorktreesHiddenCard({ + projectId, + updateRepo, + setCardState: setImportedWorktreeCardState, + hiddenWorktreePaths: getHiddenImportedWorktrees(detected).map((worktree) => worktree.path), + existingBaselinePaths: repo?.externalWorktreeInboxBaselinePaths + }) + }, + [detectedWorktreesByRepo, fetchWorktrees, repos, setImportedWorktreeCardState, updateRepo] + ) + + const setNewExternalWorktreeInboxState = useCallback( + (projectId: string, state: NewExternalWorktreesInboxActionState | null) => { + setNewExternalWorktreeInboxActionState((previous) => { + const next = new Map(previous) + if (state) { + next.set(projectId, state) + } else { + next.delete(projectId) + } + return next + }) + }, + [] + ) + + const handleOpenSuppressExternalWorktreeInbox = useCallback((projectId: string) => { + setSuppressExternalWorktreeInboxRepoId(projectId) + }, []) + + const handleConfirmSuppressExternalWorktreeInbox = useCallback(async () => { + if (!suppressExternalWorktreeInboxRepoId) { + return + } + const projectId = suppressExternalWorktreeInboxRepoId + const repo = repos.find((candidate) => candidate.id === projectId) + if (!repo) { + setSuppressExternalWorktreeInboxRepoId(null) + return + } + const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? [] + const suppressed = await suppressNewExternalWorktreeInbox({ + projectId, + repo, + worktreePaths: inboxWorktrees.map((worktree) => worktree.path), + updateRepo, + setInboxState: setNewExternalWorktreeInboxState + }) + if (suppressed) { + setSuppressExternalWorktreeInboxRepoId(null) + } + }, [ + newExternalWorktreesInboxByRepo, + repos, + setNewExternalWorktreeInboxState, + suppressExternalWorktreeInboxRepoId, + updateRepo + ]) + + return { + importedWorktreeCardActionState, + newExternalWorktreeInboxActionState, + importedWorktreesByRepo, + newExternalWorktreesInboxByRepo, + suppressExternalWorktreeInboxRepoId, + setSuppressExternalWorktreeInboxRepoId, + handleShowImportedWorktrees, + handleKeepImportedWorktreesHidden, + handleOpenSuppressExternalWorktreeInbox, + handleConfirmSuppressExternalWorktreeInbox + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts new file mode 100644 index 00000000000..0a0b4cea44a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts @@ -0,0 +1,69 @@ +import { useMemo } from 'react' +import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../../shared/execution-host' +import type { SidebarWorktreeFilters } from './use-sidebar-worktree-filters' +import { filterFolderWorkspacesFromOtherDevices } from '../workspace-creator-visibility' +import { + filterFolderWorkspacesForVisibleHosts, + filterProjectGroupsForVisibleHosts, + getVisibleSidebarHostIdSet +} from '../worktree-list-host-filtering' + +// Narrows repos, project groups, and folder workspaces to the hosts (and devices) the +// current host filter admits. +export function useSidebarHostVisibleScope(args: { + filterState: SidebarWorktreeFilters['filterState'] + defaultHostId: ExecutionHostId + repos: readonly Repo[] + projectGroups: readonly ProjectGroup[] + folderWorkspaces: readonly FolderWorkspace[] + pairedDeviceIdsByEnvironment: Parameters[1] +}) { + const { filterState, defaultHostId, repos, projectGroups, folderWorkspaces } = args + const { visibleWorkspaceHostIds, workspaceHostScope, hideWorkspacesFromOtherDevices } = + filterState + const visibleHostIdSet = useMemo( + () => getVisibleSidebarHostIdSet(visibleWorkspaceHostIds, workspaceHostScope), + [visibleWorkspaceHostIds, workspaceHostScope] + ) + const visibleReposForRows = useMemo(() => { + if (!visibleHostIdSet) { + return repos + } + return repos.filter((repo) => { + const hostId = + repo.connectionId || repo.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId + return visibleHostIdSet.has(hostId) + }) + }, [defaultHostId, repos, visibleHostIdSet]) + const visibleProjectGroupsForRows = useMemo( + () => filterProjectGroupsForVisibleHosts(projectGroups, visibleHostIdSet, defaultHostId), + [defaultHostId, projectGroups, visibleHostIdSet] + ) + const visibleFolderWorkspacesForRows = useMemo(() => { + const hostVisibleWorkspaces = filterFolderWorkspacesForVisibleHosts( + folderWorkspaces, + projectGroups, + visibleHostIdSet, + defaultHostId + ) + if (!hideWorkspacesFromOtherDevices) { + return hostVisibleWorkspaces + } + return filterFolderWorkspacesFromOtherDevices( + hostVisibleWorkspaces, + args.pairedDeviceIdsByEnvironment + ) + }, [ + args.pairedDeviceIdsByEnvironment, + defaultHostId, + folderWorkspaces, + hideWorkspacesFromOtherDevices, + projectGroups, + visibleHostIdSet + ]) + + return { visibleReposForRows, visibleProjectGroupsForRows, visibleFolderWorkspacesForRows } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-highlight.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-highlight.ts new file mode 100644 index 00000000000..82811c7cd4a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-highlight.ts @@ -0,0 +1,63 @@ +import { useCallback, useRef, useState } from 'react' + +export type SidebarRevealHighlight = ReturnType + +// Owns the frames a reveal schedules and the short glow it leaves on the revealed row. +export function useSidebarRevealHighlight() { + const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) + const pendingRevealFrameIdsRef = useRef>(new Set()) + const revealHighlightFrameIdRef = useRef(null) + const revealHighlightTimeoutRef = useRef(null) + + const cancelPendingRevealFrames = useCallback(() => { + for (const frameId of pendingRevealFrameIdsRef.current) { + window.cancelAnimationFrame(frameId) + } + pendingRevealFrameIdsRef.current.clear() + }, []) + const schedulePendingRevealFrame = useCallback((callback: FrameRequestCallback) => { + const frameId = window.requestAnimationFrame((time) => { + pendingRevealFrameIdsRef.current.delete(frameId) + callback(time) + }) + pendingRevealFrameIdsRef.current.add(frameId) + }, []) + const clearRevealHighlightFrame = useCallback(() => { + if (revealHighlightFrameIdRef.current !== null) { + window.cancelAnimationFrame(revealHighlightFrameIdRef.current) + revealHighlightFrameIdRef.current = null + } + }, []) + const clearRevealHighlightTimeout = useCallback(() => { + if (revealHighlightTimeoutRef.current !== null) { + window.clearTimeout(revealHighlightTimeoutRef.current) + revealHighlightTimeoutRef.current = null + } + }, []) + const flashRevealedRow = useCallback( + (rowKey: string) => { + clearRevealHighlightTimeout() + clearRevealHighlightFrame() + // Why: clear before set restarts the CSS glow when revealing the same row repeatedly. + setHighlightedRevealRowKey(null) + revealHighlightFrameIdRef.current = window.requestAnimationFrame(() => { + revealHighlightFrameIdRef.current = null + setHighlightedRevealRowKey(rowKey) + revealHighlightTimeoutRef.current = window.setTimeout(() => { + revealHighlightTimeoutRef.current = null + setHighlightedRevealRowKey(null) + }, 1500) + }) + }, + [clearRevealHighlightFrame, clearRevealHighlightTimeout] + ) + + return { + highlightedRevealRowKey, + cancelPendingRevealFrames, + schedulePendingRevealFrame, + clearRevealHighlightFrame, + clearRevealHighlightTimeout, + flashRevealedRow + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-requests.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-requests.ts new file mode 100644 index 00000000000..4737a7fc60a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-requests.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect } from 'react' +import { useAppStore } from '@/store' +import { + SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, + type ScrollToCurrentWorkspaceRevealRequestDetail +} from '@/lib/scroll-to-current-workspace-status' +import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeGroupBy } from '../worktree-list-groups' +import { getKnownSidebarWorktreeById } from '../worktree-list-folder-reveal' + +// Turns a "show me the current workspace" request into whatever the sidebar must change +// first — grouping mode, active filters — before the viewport can scroll to it. +export function useSidebarRevealRequests(args: { + groupBy: WorktreeGroupBy + renderedSidebarRowKeys: ReadonlySet + renderedWorktreeIds: readonly string[] + currentSidebarWorktreeId: string | null + worktreeMap: Map + folderWorkspaces: readonly FolderWorkspace[] + hasFilters: boolean + clearFilters: () => void +}): void { + const { + groupBy, + renderedSidebarRowKeys, + renderedWorktreeIds, + currentSidebarWorktreeId, + worktreeMap, + folderWorkspaces, + hasFilters, + clearFilters + } = args + const setGroupBy = useAppStore((s) => s.setGroupBy) + const pendingRevealSidebarRow = useAppStore((s) => s.pendingRevealSidebarRow) + const revealSidebarRow = useAppStore((s) => s.revealSidebarRow) + const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) + + useEffect(() => { + if (!pendingRevealSidebarRow) { + return + } + const rowKey = pendingRevealSidebarRow.rowKey + const isProjectHeaderTarget = + rowKey.startsWith('project-group:') || + rowKey.startsWith('project:') || + rowKey.startsWith('repo:') + if (isProjectHeaderTarget && groupBy !== 'repo') { + setGroupBy('repo') + return + } + if (!renderedSidebarRowKeys.has(rowKey) && hasFilters) { + clearFilters() + } + }, [ + clearFilters, + groupBy, + hasFilters, + pendingRevealSidebarRow, + renderedSidebarRowKeys, + setGroupBy + ]) + + const handleRevealCurrentWorkspaceRequest = useCallback( + (event: Event) => { + const detail = + event instanceof CustomEvent + ? (event.detail as ScrollToCurrentWorkspaceRevealRequestDetail | undefined) + : undefined + if (detail?.target?.type === 'sidebar-row') { + const sidebarDetail = detail as Extract< + ScrollToCurrentWorkspaceRevealRequestDetail, + { target: { type: 'sidebar-row' } } + > + revealSidebarRow(detail.target.rowKey, { + behavior: 'smooth', + highlight: sidebarDetail.highlight !== false + }) + return + } + if (!currentSidebarWorktreeId) { + return + } + const activeWorktree = getKnownSidebarWorktreeById( + currentSidebarWorktreeId, + worktreeMap, + folderWorkspaces + ) + if (!activeWorktree || activeWorktree.isArchived) { + return + } + if (!renderedWorktreeIds.includes(currentSidebarWorktreeId)) { + // Why: the reveal action must show the current workspace, so relax filters that hide it first. + clearFilters() + } + revealWorktreeInSidebar(currentSidebarWorktreeId, { + behavior: 'smooth', + highlight: true, + beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true + }) + }, + [ + clearFilters, + currentSidebarWorktreeId, + folderWorkspaces, + revealSidebarRow, + renderedWorktreeIds, + revealWorktreeInSidebar, + worktreeMap + ] + ) + + useEffect(() => { + window.addEventListener( + SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, + handleRevealCurrentWorkspaceRequest + ) + return () => { + window.removeEventListener( + SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, + handleRevealCurrentWorkspaceRequest + ) + } + }, [handleRevealCurrentWorkspaceRequest]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts new file mode 100644 index 00000000000..9b139e88719 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts @@ -0,0 +1,260 @@ +import { useCallback, useMemo, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { ProjectOrderBy } from '../../../../../shared/ui-chrome-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import { getHostDisplayLabelOverrides } from '../../../../../shared/host-setting-overrides' +import { + buildRows, + type PinnedWorktreeDisplayPolicy, + type ProjectGroupingModel, + type Row, + type WorktreeGroupBy +} from '../worktree-list-groups' +import { getLogicalRepoOrderRankById } from '../project-header-drop' +import { getEmptyProjectPlaceholderRepoIds } from '../empty-project-placeholder-repos' +import { addHostSectionRows } from '../host-section-rows' +import { orderHostSectionOptions } from '../host-section-order' +import { buildSidebarHostOptions } from '../sidebar-host-options' + +type SectionRowsArgs = { + groupBy: WorktreeGroupBy + projectOrderBy: ProjectOrderBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + defaultHostId: ExecutionHostId + worktrees: Worktree[] + repos: readonly Repo[] + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + prCache: AppState['prCache'] | null + settings: AppState['settings'] + workspaceStatuses: readonly WorkspaceStatusDefinition[] + effectiveCollapsedGroups: Set + projectGrouping: ProjectGroupingModel + visibleReposForRows: readonly Repo[] + visibleProjectGroupsForRows: readonly ProjectGroup[] + visibleFolderWorkspacesForRows: readonly FolderWorkspace[] + importedWorktreesByRepo: Parameters[14] + newExternalWorktreesInboxByRepo: Parameters[15] + filterRepoIds: readonly string[] + visibleWorkspaceHostIds: readonly ExecutionHostId[] | null + workspaceHostScope: AppState['workspaceHostScope'] +} + +function collectRenderedSidebarRowKeys(sectionRows: ReturnType) { + const keys = new Set() + for (const row of sectionRows) { + if (row.type === 'header') { + keys.add(row.key) + } else if (row.type === 'item') { + keys.add(row.rowKey) + } else if (row.type === 'folder-workspace') { + keys.add(folderWorkspaceKey(row.folderWorkspace.id)) + } else if (row.type === 'pending-creation') { + keys.add(`pending:${row.creationId}`) + } else if (row.type === 'imported-worktrees-card') { + keys.add(row.key) + } else if (row.type === 'new-external-worktrees-inbox') { + keys.add(row.key) + } + } + return keys +} + +// Builds the full sidebar row model: grouped worktree rows first, then the host-section +// tier wrapped around them. +export function useSidebarSectionRows(args: SectionRowsArgs) { + const { repos, worktrees, repoMap, effectiveCollapsedGroups, defaultHostId } = args + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder) + const setWorkspaceHostOrder = useAppStore((s) => s.setWorkspaceHostOrder) + + // Why: manual header order is bound to state.repos; Recent/Smart derive order from the sorted worktree stream. + const repoOrder = useMemo( + () => getLogicalRepoOrderRankById(repos.map((repo) => repo.id)), + [repos] + ) + const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) + const placeholderRepoIds = useMemo( + () => + getEmptyProjectPlaceholderRepoIds({ + groupBy: args.groupBy, + repos: args.visibleReposForRows, + worktreesByRepo, + visibleWorktrees: worktrees, + filterRepoIds: args.filterRepoIds + }), + [args.filterRepoIds, args.groupBy, args.visibleReposForRows, worktrees, worktreesByRepo] + ) + + // Why: subscribe on a flat key array (useShallow) so progress ticks don't rebuild the whole row model. + // Split on first space — creationId is a UUID (no space) so a space-containing repoId stays intact. + const pendingCreationKeys = useAppStore( + useShallow((s) => + Object.values(s.pendingWorktreeCreations ?? {}).map( + (creation) => `${creation.creationId} ${creation.request.repoId}` + ) + ) + ) + const pendingCreations = useMemo( + () => + pendingCreationKeys.map((key) => { + const separator = key.indexOf(' ') + return { creationId: key.slice(0, separator), repoId: key.slice(separator + 1) } + }), + [pendingCreationKeys] + ) + const hostLabelOverrides = useMemo( + () => getHostDisplayLabelOverrides(args.settings), + [args.settings] + ) + const hostOptions = useMemo( + () => + buildSidebarHostOptions({ + repos, + sshTargetLabels, + sshConnectionStates, + settings: args.settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }), + [ + repos, + sshTargetLabels, + sshConnectionStates, + args.settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) + const hostLabelById = useMemo( + () => new Map(hostOptions.map((host) => [host.id, host.label])), + [hostOptions] + ) + + const rows: Row[] = useMemo( + () => + buildRows( + args.groupBy, + worktrees, + repoMap, + args.prCache, + effectiveCollapsedGroups, + repoOrder, + args.workspaceStatuses, + args.projectOrderBy, + args.worktreeLineageById, + args.worktreeMap, + true, + args.settings, + args.visibleProjectGroupsForRows, + placeholderRepoIds, + args.importedWorktreesByRepo, + args.newExternalWorktreesInboxByRepo, + pendingCreations, + args.projectGrouping, + args.visibleFolderWorkspacesForRows, + hostLabelById, + defaultHostId, + args.pinnedDisplayPolicy + ), + [ + args.groupBy, + worktrees, + repoMap, + args.prCache, + effectiveCollapsedGroups, + defaultHostId, + repoOrder, + args.workspaceStatuses, + args.projectOrderBy, + args.worktreeLineageById, + args.worktreeMap, + args.settings, + args.projectGrouping, + args.visibleProjectGroupsForRows, + args.visibleFolderWorkspacesForRows, + placeholderRepoIds, + args.importedWorktreesByRepo, + args.newExternalWorktreesInboxByRepo, + pendingCreations, + hostLabelById, + args.pinnedDisplayPolicy + ] + ) + const orderedHostOptions = useMemo( + () => orderHostSectionOptions(hostOptions, workspaceHostOrder), + [hostOptions, workspaceHostOrder] + ) + const [hostDragActive, setHostDragActive] = useState(false) + const handleReorderHostSections = useCallback( + (orderedVisibleHostIds: ExecutionHostId[]) => { + const visibleHostIds = new Set(orderedVisibleHostIds) + const hostOptionIds = orderedHostOptions.map((host) => host.id) + const knownHostIds = new Set(hostOptionIds) + const nextOrder: ExecutionHostId[] = [...orderedVisibleHostIds] + const seen = new Set(nextOrder) + // Why: dragging only covers rendered hosts; keep non-rendered SSH/runtime hosts in the saved order so they return in place. + for (const hostId of [...workspaceHostOrder, ...hostOptionIds]) { + if (!knownHostIds.has(hostId) || visibleHostIds.has(hostId) || seen.has(hostId)) { + continue + } + nextOrder.push(hostId) + seen.add(hostId) + } + setWorkspaceHostOrder(nextOrder) + }, + [orderedHostOptions, setWorkspaceHostOrder, workspaceHostOrder] + ) + const sectionRows = useMemo( + () => + addHostSectionRows({ + rows, + hostOptions: orderedHostOptions, + workspaceHostScope: args.workspaceHostScope, + visibleWorkspaceHostIds: args.visibleWorkspaceHostIds, + defaultHostId, + collapsedHostKeys: effectiveCollapsedGroups, + forceCollapseHosts: hostDragActive, + // Why: projects/workspaces are the primary sidebar object; host sections are only an explicit host-filter view. + preferProjectGrouping: true + }), + [ + args.visibleWorkspaceHostIds, + args.workspaceHostScope, + defaultHostId, + effectiveCollapsedGroups, + hostDragActive, + orderedHostOptions, + rows + ] + ) + const renderedSidebarRowKeys = useMemo( + () => collectRenderedSidebarRowKeys(sectionRows), + [sectionRows] + ) + + return { + rows, + sectionRows, + renderedSidebarRowKeys, + allRepoIds, + placeholderRepoIds, + handleReorderHostSections, + setHostDragActive + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts new file mode 100644 index 00000000000..7f59d344e6d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts @@ -0,0 +1,106 @@ +import { useCallback, useMemo } from 'react' +import { useAppStore } from '@/store' +import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../../shared/constants' +import { computeClearFilterActions, sidebarHasActiveFilters } from '../visible-worktrees' + +export type SidebarWorktreeFilters = ReturnType + +// Every sidebar filter, plus the single escape hatch that resets all of them. +export function useSidebarWorktreeFilters() { + const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) + const filterRepoIds = useAppStore((s) => s.filterRepoIds) + const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) + const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) + const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) + const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) + + const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) + const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) + const setHideAutomationGeneratedWorkspaces = useAppStore( + (s) => s.setHideAutomationGeneratedWorkspaces + ) + const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) + const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const setHideWorkspacesFromOtherDevices = useAppStore((s) => s.setHideWorkspacesFromOtherDevices) + const setAlwaysShowDefaultBranchWorkspace = useAppStore( + (s) => s.setAlwaysShowDefaultBranchWorkspace + ) + const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) + const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) + + // Why: count hideDefaultBranchWorkspace as a filter so the Clear Filters escape hatch stays reachable when it alone empties the list. + const filterState = useMemo( + () => ({ + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace, + visibleWorkspaceHostIds, + workspaceHostScope + }), + [ + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace, + visibleWorkspaceHostIds, + workspaceHostScope + ] + ) + + const clearFilters = useCallback(() => { + const actions = computeClearFilterActions(filterState) + if (actions.resetShowSleepingWorkspaces) { + setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES) + } + if (actions.resetFilterRepoIds) { + setFilterRepoIds([]) + } + if (actions.resetHideDefaultBranchWorkspace) { + setHideDefaultBranchWorkspace(false) + } + if (actions.resetHideAutomationGeneratedWorkspaces) { + setHideAutomationGeneratedWorkspaces(false) + } + if (actions.resetHideCliCreatedWorkspaces) { + setHideCliCreatedWorkspaces(false) + } + if (actions.resetHideDetachedHeadWorkspaces) { + setHideDetachedHeadWorkspaces(false) + } + if (actions.resetHideWorkspacesFromOtherDevices) { + setHideWorkspacesFromOtherDevices(false) + } + if (actions.resetAlwaysShowDefaultBranchWorkspace) { + setAlwaysShowDefaultBranchWorkspace(true) + } + if (actions.resetVisibleWorkspaceHostIds) { + setVisibleWorkspaceHostIds(null) + } + }, [ + setShowSleepingWorkspaces, + setFilterRepoIds, + setHideDefaultBranchWorkspace, + setHideAutomationGeneratedWorkspaces, + setHideCliCreatedWorkspaces, + setHideDetachedHeadWorkspaces, + setHideWorkspacesFromOtherDevices, + setAlwaysShowDefaultBranchWorkspace, + setVisibleWorkspaceHostIds, + filterState + ]) + + return { filterState, hasFilters: sidebarHasActiveFilters(filterState), clearFilters } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts new file mode 100644 index 00000000000..426385c6063 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' +import type React from 'react' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { HostSectionRow } from '../host-section-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import { getRenderedWorktreesInSidebarOrder } from '../worktree-sidebar-row-preference' +import { setVisibleWorktreeIds } from '../visible-worktrees' +import { + areWorktreeSelectionsEqual, + getWorktreeSelectionIntent, + pruneWorktreeSelection, + updateWorktreeSelection +} from '../worktree-multi-selection' +import { useReusedArrayIdentity } from './use-reused-array-identity' + +function uniqueWorktreeIds(ids: readonly string[]): string[] { + return Array.from(new Set(ids)) +} + +// Multi-select over the rows the sidebar actually rendered, so gestures, context menus, and +// the Cmd+1–9 shortcut cache all agree on one order. +export function useSidebarWorktreeSelection(args: { + sectionRows: HostSectionRow[] + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +}) { + const { sectionRows, pinnedDisplayPolicy } = args + // Why: derive order from the built rows, not the flat worktrees array, so Cmd+1–9 match visual positions when grouping reorders cards. + const renderedWorktrees = useMemo( + () => getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy), + [pinnedDisplayPolicy, sectionRows] + ) + // Why: order-preserving sectionRows rebuilds must not give this array a new + // identity — updateSelectionForGesture depends on it, and a fresh identity + // there defeats React.memo bail-out for every WorktreeCard on epoch bumps. + const renderedWorktreeIds = useReusedArrayIdentity( + useMemo( + () => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)), + [renderedWorktrees] + ) + ) + const [selectedWorktreeIds, setSelectedWorktreeIds] = useState>(new Set()) + const [selectionAnchorId, setSelectionAnchorId] = useState(null) + + const prunedSelection = pruneWorktreeSelection( + selectedWorktreeIds, + selectionAnchorId, + renderedWorktreeIds + ) + // Why: filters/grouping can hide selected cards; prune during render so nothing sees stale ids for unrendered worktrees. + if (!areWorktreeSelectionsEqual(selectedWorktreeIds, prunedSelection.selectedIds)) { + setSelectedWorktreeIds(prunedSelection.selectedIds) + } + if (selectionAnchorId !== prunedSelection.anchorId) { + setSelectionAnchorId(prunedSelection.anchorId) + } + + // Why identity reuse: the empty/unchanged-selection case must keep one array + // identity — selectForContextMenu and both drag-start handlers depend on + // this array, and card memo bail-out depends on those staying stable. + const selectedWorktrees = useReusedArrayIdentity( + useMemo(() => { + if (selectedWorktreeIds.size === 0) { + return [] + } + const selected = new Map() + for (const worktree of renderedWorktrees) { + if (selectedWorktreeIds.has(worktree.id) && !selected.has(worktree.id)) { + selected.set(worktree.id, worktree) + } + } + return Array.from(selected.values()) + }, [renderedWorktrees, selectedWorktreeIds]) + ) + + useEffect(() => { + if (selectedWorktreeIds.size === 0) { + return + } + + const clearSelectionOutsideSidebar = (event: PointerEvent): void => { + const target = event.target + const sidebarContainer = document.querySelector('[data-worktree-sidebar-container]') + if (target instanceof Node && sidebarContainer?.contains(target)) { + return + } + setSelectedWorktreeIds(new Set()) + setSelectionAnchorId(null) + } + + document.addEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) + return () => { + document.removeEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) + } + }, [selectedWorktreeIds.size]) + + const updateSelectionForGesture = useCallback( + (event: React.MouseEvent, worktreeId: string): boolean => { + const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac')) + const result = updateWorktreeSelection({ + visibleIds: renderedWorktreeIds, + previousSelectedIds: selectedWorktreeIds, + previousAnchorId: selectionAnchorId, + targetId: worktreeId, + intent + }) + setSelectedWorktreeIds(result.selectedIds) + setSelectionAnchorId(result.anchorId) + // Plain click navigates; modifier gestures are selection-only so a batch can build without switching away. + return intent !== 'replace' + }, + [renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId] + ) + + const selectForContextMenu = useCallback( + (_event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => { + if (selectedWorktreeIds.has(worktree.id) && selectedWorktreeIds.size > 1) { + return selectedWorktrees + } + setSelectedWorktreeIds(new Set([worktree.id])) + setSelectionAnchorId(worktree.id) + return [worktree] + }, + [selectedWorktreeIds, selectedWorktrees] + ) + + // Why layout effect: the Cmd/Ctrl+1–9 handler can fire right after commit; publishing after paint would leave the shortcut cache stale. + useLayoutEffect(() => { + setVisibleWorktreeIds(renderedWorktreeIds) + // Why null, not []: [] is a real rendered order (all collapsed/filtered); null tells shortcuts the list is unmounted. + return () => setVisibleWorktreeIds(null) + }, [renderedWorktreeIds]) + + return { + renderedWorktreeIds, + selectedWorktreeIds, + selectedWorktrees, + updateSelectionForGesture, + selectForContextMenu + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts new file mode 100644 index 00000000000..64b2e426abf --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts @@ -0,0 +1,207 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { getAllWorktreesFromState } from '@/store/selectors' +import { track } from '@/lib/telemetry' +import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import { persistWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-persistence' +import type { Repo } from '../../../../../shared/repo-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import { buildWorktreeComparator, compareWorktreeSortLabel, type SortBy } from '../smart-sort' +import { + buildAttentionByWorktree, + hasFreshAttributedAgentStatus, + type SmartClass, + type WorktreeAttention +} from '../smart-attention' +import { useReusedArrayIdentity } from './use-reused-array-identity' +import { SORT_SETTLE_MS } from './worktree-sidebar-scroll-tuning' + +function trackSmartClassDistribution(attention: ReadonlyMap): void { + let class1 = 0 + let class2 = 0 + let class3 = 0 + let class4 = 0 + for (const info of attention.values()) { + if (info.cls === 1) { + class1++ + } else if (info.cls === 2) { + class2++ + } else if (info.cls === 3) { + class3++ + } else { + class4++ + } + } + track('smart_sort_class_distribution', { + class_1: class1, + class_2: class2, + class_3: class3, + class_4: class4, + total_worktrees: attention.size + }) +} + +// Why debounce: scores are time-decaying, so recomputing on every sortEpoch bump makes worktrees jump; settle to coalesce. +// Structural changes (add/remove) bypass the debounce so a new worktree appears at its sorted position immediately. +function useDebouncedSortEpoch(worktreeCount: number, sortBy: SortBy): number { + const sortEpoch = useAppStore((s) => s.sortEpoch) + const [debouncedSortEpoch, setDebouncedSortEpoch] = useState(sortEpoch) + const prevWorktreeCountRef = useRef(worktreeCount) + useEffect(() => { + if (debouncedSortEpoch === sortEpoch) { + return + } + + const structuralChange = worktreeCount !== prevWorktreeCountRef.current + prevWorktreeCountRef.current = worktreeCount + + // Why: manual drag/drop is direct manipulation; the settle-window delay would make a successful drop look broken. + if (structuralChange || sortBy === 'manual') { + setDebouncedSortEpoch(sortEpoch) + return + } + + const timer = setTimeout(() => setDebouncedSortEpoch(sortEpoch), SORT_SETTLE_MS) + return () => clearTimeout(timer) + }, [sortEpoch, debouncedSortEpoch, worktreeCount, sortBy]) + return debouncedSortEpoch +} + +// ── Stable sort order ────────────────────────────────────────── +// Why sortEpoch (not selection): selection side-effects (clearing isUnread, PR-cache refresh) must not reorder the sidebar under the user. +// Why useMemo not useEffect: order must be computed synchronously before the worktrees memo reads it. +export function useSidebarWorktreeSortOrder(args: { + allWorktrees: readonly Worktree[] + repoMap: Map + sortBy: SortBy +}): string[] { + const { allWorktrees, repoMap, sortBy } = args + // Non-archived count — detects structural changes (add/remove) so the debounce below can apply immediately. + const worktreeCount = useMemo(() => { + let count = 0 + for (const worktree of allWorktrees) { + if (!worktree.isArchived) { + count++ + } + } + return count + }, [allWorktrees]) + const debouncedSortEpoch = useDebouncedSortEpoch(worktreeCount, sortBy) + + // Why a latching ref: a live signal makes Smart authoritative for the session, even after that activity ends. + const sessionHasHadLiveSmartSignal = useRef(false) + // Why a ref alongside the memo: telemetry effects need the last attention map without re-reading store state. + const lastAttentionByWorktreeRef = useRef | null>(null) + + const recomputedSortedIds = useMemo(() => { + const state = useAppStore.getState() + const nonArchivedWorktrees = getAllWorktreesFromState(state).filter( + (worktree) => !worktree.isArchived + ) + const now = Date.now() + + // Why cold-start detection: agent-status hydrates async, so the warm comparator would collapse all to Class 4; keep the persisted order until a live signal appears. + if (sortBy === 'smart' && !sessionHasHadLiveSmartSignal.current) { + // Why tabHasLivePty over tab.ptyId: slept terminals keep tab.ptyId as a wake hint, so it'd falsely keep cold-start ordering off. + const hasAnyLivePty = Object.values(state.tabsByWorktree) + .flat() + .some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) + if ( + hasAnyLivePty || + hasFreshAttributedAgentStatus(state.agentStatusByPaneKey, now, state.tabsByWorktree) + ) { + sessionHasHadLiveSmartSignal.current = true + } else { + nonArchivedWorktrees.sort( + (a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b) + ) + lastAttentionByWorktreeRef.current = null + return nonArchivedWorktrees.map((w) => w.id) + } + } + + // Why precompute: hot sort — build the attention map once so the O(N log N) comparator does O(1) lookups. + const attentionByWorktree = + sortBy === 'smart' + ? buildAttentionByWorktree( + nonArchivedWorktrees, + state.tabsByWorktree, + state.agentStatusByPaneKey, + state.runtimePaneTitlesByTabId, + state.ptyIdsByTabId, + now, + state.migrationUnsupportedByPtyId, + state.terminalLayoutsByTabId + ) + : new Map() + lastAttentionByWorktreeRef.current = sortBy === 'smart' ? attentionByWorktree : null + nonArchivedWorktrees.sort(buildWorktreeComparator(sortBy, repoMap, now, attentionByWorktree)) + return nonArchivedWorktrees.map((w) => w.id) + // debouncedSortEpoch is an intentional trigger not read in the memo; its change (debounced) signals a recompute. + // oxlint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSortEpoch, repoMap, sortBy]) + // Why: stable ID order prevents rank-only refreshes from echoing an unchanged snapshot. + const sortedIds = useReusedArrayIdentity(recomputedSortedIds) + + // Why a ref of prior class: fire class_1_promotion only on transitions into Class 1, not every recompute that stays there. + const prevClassByWorktreeIdRef = useRef>(new Map()) + // Why gate the first observation: an empty prev-class map makes every existing Class-1 worktree look freshly promoted; treat the first pass as a silent baseline. + const hasObservedSmartOnceRef = useRef(false) + + useEffect(() => { + const attention = lastAttentionByWorktreeRef.current + if (sortBy !== 'smart' || !attention) { + // Why reset: leaving Smart drops the prior-class map (and first-observation gate) so re-entry doesn't fire stale promotions. + prevClassByWorktreeIdRef.current = new Map() + hasObservedSmartOnceRef.current = false + return + } + const next = new Map() + const isFirstObservation = !hasObservedSmartOnceRef.current + for (const [worktreeId, info] of attention) { + const prev = prevClassByWorktreeIdRef.current.get(worktreeId) + if (!isFirstObservation && info.cls === 1 && prev !== 1 && info.cause) { + track('smart_sort_class_1_promotion', { cause: info.cause }) + } + next.set(worktreeId, info.cls) + } + prevClassByWorktreeIdRef.current = next + hasObservedSmartOnceRef.current = true + }, [sortBy, recomputedSortedIds]) + + // Why retry on recomputation: Smart may activate before attention hydrates; fire once, then stay quiet until the user leaves Smart. + const hasTrackedSmartDistributionRef = useRef(false) + useEffect(() => { + if (sortBy !== 'smart') { + hasTrackedSmartDistributionRef.current = false + return + } + const attention = lastAttentionByWorktreeRef.current + if (hasTrackedSmartDistributionRef.current || !attention || attention.size === 0) { + return + } + trackSmartClassDistribution(attention) + hasTrackedSmartDistributionRef.current = true + }, [sortBy, recomputedSortedIds]) + + // Why fire on the transition: switching away from Smart is the signal; compare via ref so a round-trip doesn't double-fire. + const prevSortByRef = useRef(sortBy) + useEffect(() => { + const prev = prevSortByRef.current + prevSortByRef.current = sortBy + if (prev === 'smart' && sortBy === 'recent') { + track('smart_to_recent_switch', {}) + } + }, [sortBy]) + + // Why: only persist during live sessions so cold start reads the persisted order instead of overwriting it. + useEffect(() => { + if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadLiveSmartSignal.current) { + return + } + // Why: sortOrder lives in each host's worktreeMeta, so persist each host's ids on that host. + persistWorktreeSortOrderByHost(useAppStore.getState(), sortedIds) + }, [sortedIds, sortBy]) + + return sortedIds +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-virtual-row-measurement-sync.ts b/src/renderer/src/components/sidebar/worktree-list/use-virtual-row-measurement-sync.ts new file mode 100644 index 00000000000..79f0d53b429 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-virtual-row-measurement-sync.ts @@ -0,0 +1,116 @@ +import { useCallback, useLayoutEffect, useMemo } from 'react' +import type React from 'react' +import { useAppStore } from '@/store' +import { + useVirtualizedScrollAnchor, + type VirtualizedScrollAnchor +} from '@/hooks/useVirtualizedScrollAnchor' +import { + buildLineageRowRekeyMap, + getActiveStickyIndexesForScroll, + getRenderRowKey, + pruneStaleVirtualRowElementCache, + type RenderRow +} from '../worktree-list-virtual-rows' +import { getVirtualRowKey } from './virtual-row-dom-attributes' +import { countRecordKeysByReference } from './worktree-sidebar-scroll-tuning' +import type { WorktreeListVirtualizer } from './use-worktree-list-virtualizer' + +// Re-measures only rows whose DOM node still matches its virtual key, and publishes the +// sticky-header slots the render pass reads out of refs. +export function useVirtualRowMeasurementSync(args: { + renderRows: RenderRow[] + virtualization: WorktreeListVirtualizer + scrollRef: React.RefObject + scrollOffsetRef: React.MutableRefObject + scrollAnchorRef: React.MutableRefObject + hasDirectScrollInput: () => boolean + shouldSkipScrollAnchorRestore: () => boolean +}) { + const { + renderRows, + virtualization, + scrollRef, + scrollOffsetRef, + scrollAnchorRef, + hasDirectScrollInput, + shouldSkipScrollAnchorRestore + } = args + const { virtualizer, isCurrentVirtualRowElement } = virtualization + const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache)) + const issueCacheLen = useAppStore((s) => countRecordKeysByReference(s.issueCache)) + const renderRowKeySignature = useMemo( + () => renderRows.map(getRenderRowKey).join('\n'), + [renderRows] + ) + const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows]) + const lineageRowRekeys = useMemo(() => buildLineageRowRekeyMap(renderRows), [renderRows]) + const totalSize = virtualizer.getTotalSize() + const virtualItems = virtualizer.getVirtualItems() + const activeStickyIndexes = getActiveStickyIndexesForScroll({ + rows: renderRows, + rangeStartIndex: virtualization.stickyRangeStartIndexRef.current, + scrollOffset: virtualizer.scrollOffset ?? scrollOffsetRef.current, + stickyHeaderIndexes: virtualization.stickyHeaderIndexes, + virtualItems + }) + virtualization.activeStickyHeaderIndexRef.current = activeStickyIndexes.groupIndex + virtualization.activeStickyHostIndexRef.current = activeStickyIndexes.hostIndex + + const measureMountedRows = useCallback(() => { + virtualizer.elementsCache.forEach((element) => { + if (!isCurrentVirtualRowElement(element)) { + return + } + virtualizer.measureElement(element) + }) + }, [isCurrentVirtualRowElement, virtualizer]) + const measureVirtualRowElement = useCallback( + (element: HTMLDivElement | null) => { + if (!element) { + virtualizer.measureElement(null) + return + } + if (!isCurrentVirtualRowElement(element)) { + return + } + virtualizer.measureElement(element) + }, + [isCurrentVirtualRowElement, virtualizer] + ) + + useLayoutEffect(() => { + pruneStaleVirtualRowElementCache({ + activeRowKeys: activeRenderRowKeys, + virtualizer + }) + // Why: a stale retained element after delete/collapse measures 0px and corrupts the next slot; measure only key-matched rows. + measureMountedRows() + const frameId = window.requestAnimationFrame(measureMountedRows) + return () => window.cancelAnimationFrame(frameId) + }, [ + activeRenderRowKeys, + prCacheLen, + issueCacheLen, + measureMountedRows, + renderRowKeySignature, + virtualizer + ]) + + useVirtualizedScrollAnchor({ + anchorRef: scrollAnchorRef, + getItemElementKey: getVirtualRowKey, + getRowKey: getRenderRowKey, + itemElementSelector: '[data-worktree-virtual-row]', + rekeyedRowKeys: lineageRowRekeys, + rows: renderRows, + scrollElementRef: scrollRef, + scrollOffsetRef, + hasDirectScrollInput, + shouldSkipRestore: shouldSkipScrollAnchorRestore, + totalSize, + virtualizer + }) + + return { virtualItems, measureVirtualRowElement } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-visible-pr-refresh-reporting.ts b/src/renderer/src/components/sidebar/worktree-list/use-visible-pr-refresh-reporting.ts new file mode 100644 index 00000000000..ba6bafc0b5a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-visible-pr-refresh-reporting.ts @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from 'react' +import type React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import { useAppStore } from '@/store' +import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeGroupBy } from '../worktree-list-groups' +import type { RenderRow } from '../worktree-list-virtual-rows' +import type { WorktreeItemRow } from './render-row-item-rows' +import { installWorktreeVisibleRefreshVisibilityListener } from './worktree-visible-refresh-listener' + +const DOCUMENT_HIDDEN_KEY = '__document_hidden__' +const NOTHING_TO_TRACK_KEY = '__hidden__' + +// Reports which sidebar rows are on screen so the GitHub PR/CI coordinator can refresh +// exactly those, and no more. +export function useVisiblePrRefreshReporting(args: { + currentWorktreeId: string | null + worktreeMap: Map + groupBy: WorktreeGroupBy + newCardStyle: boolean + renderRows: RenderRow[] + virtualItems: readonly VirtualItem[] + scrollRef: React.RefObject +}): void { + const { + currentWorktreeId, + worktreeMap, + groupBy, + newCardStyle, + renderRows, + virtualItems, + scrollRef + } = args + const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) + const lastVisibleRefreshKeyRef = useRef('') + const reportVisibleGitHubPRRefreshCandidates = useAppStore( + (s) => s.reportVisibleGitHubPRRefreshCandidates + ) + const cardProps = useAppStore((s) => s.worktreeCardProperties) + const rightSidebarShowsPR = useAppStore((s) => rightSidebarShowsPullRequestData(s)) + const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) + const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration) + + useEffect( + () => + installWorktreeVisibleRefreshVisibilityListener(() => { + if (document.visibilityState !== 'visible') { + // Why: row identity may be unchanged after a hidden window; reset the key so PR/CI rows refresh. + lastVisibleRefreshKeyRef.current = DOCUMENT_HIDDEN_KEY + return + } + setDocumentVisibilityRevision((revision) => revision + 1) + }), + [] + ) + + useEffect(() => { + if (document.visibilityState !== 'visible') { + lastVisibleRefreshKeyRef.current = DOCUMENT_HIDDEN_KEY + return + } + const currentWorktree = currentWorktreeId ? (worktreeMap.get(currentWorktreeId) ?? null) : null + // Why: this reporter feeds the GitHub coordinator; GitLab-only MR panels refresh via hosted-review paths. + const sidebarWorktreeHasGitHubReview = + currentWorktree !== null && + ((currentWorktree.linkedGitLabMR ?? null) === null || + (currentWorktree.linkedPR ?? null) !== null) + const shouldTrackSidebarWorktree = rightSidebarShowsPR && sidebarWorktreeHasGitHubReview + const shouldTrackVisibleRows = + groupBy === 'pr-status' || + (newCardStyle + ? cardProps.includes('status') + : cardProps.includes('pr') || cardProps.includes('ci')) + if (!shouldTrackVisibleRows && !shouldTrackSidebarWorktree) { + if (lastVisibleRefreshKeyRef.current !== NOTHING_TO_TRACK_KEY) { + lastVisibleRefreshKeyRef.current = NOTHING_TO_TRACK_KEY + reportVisibleGitHubPRRefreshCandidates([], Date.now()) + } + return + } + const scrollEl = scrollRef.current + if (!scrollEl) { + return + } + const viewportTop = scrollEl.scrollTop + const viewportBottom = viewportTop + scrollEl.clientHeight + const visibleRows = virtualItems + .filter((item) => item.start < viewportBottom && item.end > viewportTop) + .map((item) => renderRows[item.index]) + .filter((row): row is WorktreeItemRow => row?.type === 'item') + .filter((row) => row.repo?.kind === 'git' && !row.worktree.isBare && row.worktree.branch) + const visibleWorktreeIds = new Set(visibleRows.map((row) => row.worktree.id)) + if ( + shouldTrackSidebarWorktree && + currentWorktree && + !currentWorktree.isBare && + currentWorktree.branch + ) { + visibleWorktreeIds.add(currentWorktree.id) + } + const visibleIdentity = visibleRows + .map((row) => `${row.worktree.id}:${row.worktree.branch}:${row.worktree.linkedPR ?? ''}`) + .join('|') + const sidebarIdentity = + shouldTrackSidebarWorktree && currentWorktree + ? `${currentWorktree.id}:${currentWorktree.branch}:${currentWorktree.linkedPR ?? ''}` + : '' + const key = `${visibleIdentity}:${sidebarIdentity}:${sshConnectedGeneration}:${prVisibleRefreshGeneration}:${cardProps.join(',')}` + if (!key || key === lastVisibleRefreshKeyRef.current) { + return + } + lastVisibleRefreshKeyRef.current = key + reportVisibleGitHubPRRefreshCandidates(Array.from(visibleWorktreeIds), Date.now()) + }, [ + cardProps, + currentWorktreeId, + documentVisibilityRevision, + groupBy, + renderRows, + reportVisibleGitHubPRRefreshCandidates, + prVisibleRefreshGeneration, + rightSidebarShowsPR, + scrollRef, + sshConnectedGeneration, + newCardStyle, + virtualItems, + worktreeMap + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts b/src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts new file mode 100644 index 00000000000..bd1f37fc708 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts @@ -0,0 +1,134 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' +import type { AppState } from '@/store/types' +import type { Repo } from '../../../../../shared/repo-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types' +import { getSettingsFocusedExecutionHostId } from '../../../../../shared/execution-host' +import { computeVisibleWorktreeIds } from '../visible-worktrees' +import { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + getPairedDeviceIdsByEnvironment +} from '../workspace-creator-visibility' +import { + getVisibleWorktreeBrowserActivityTabs, + getVisibleWorktreeTerminalActivityTabs +} from '../visible-worktree-activity-inputs' +import type { SortBy } from '../smart-sort' +import type { SidebarWorktreeFilters } from './use-sidebar-worktree-filters' +import { EMPTY_WORKTREE_ID_SET } from './stable-empty-store-slices' +import { useReusedArrayIdentity } from './use-reused-array-identity' + +// Applies every sidebar filter to the sorted id stream. Flatten/filter/sort goes through the +// shared utility so card order matches Cmd+1–9 numbering. +export function useVisibleSidebarWorktrees(args: { + filterState: SidebarWorktreeFilters['filterState'] + sortBy: SortBy + sortedIds: string[] + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + settings: AppState['settings'] + agentSendTargetWorktreeId: string | null +}) { + const { filterState, sortBy, sortedIds, repoMap, worktreeMap, worktreeLineageById, settings } = + args + const { + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace, + visibleWorkspaceHostIds, + workspaceHostScope + } = filterState + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0)) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const pairedDeviceIdsByEnvironment = useMemo( + () => + hideWorkspacesFromOtherDevices + ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + [hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId] + ) + + // Read tabsByWorktree when needed for filtering or sorting + const needsActivityMaps = !showSleepingWorkspaces || sortBy === 'smart' + const tabsByWorktree = useAppStore((s) => + needsActivityMaps ? getVisibleWorktreeTerminalActivityTabs(s.tabsByWorktree) : null + ) + const ptyIdsByTabId = useAppStore((s) => (needsActivityMaps ? s.ptyIdsByTabId : null)) + const browserTabsByWorktree = useAppStore((s) => + !showSleepingWorkspaces ? getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree) : null + ) + + const recomputedVisibleWorktrees = useMemo(() => { + void agentStatusEpoch + const ids = computeVisibleWorktreeIds(worktreesByRepo, sortedIds, { + filterRepoIds, + showSleepingWorkspaces, + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + // Why snapshot on agentStatusEpoch: update membership immediately without repainting on every hook ping. + worktreeIdsWithLiveAgent: showSleepingWorkspaces + ? EMPTY_WORKTREE_ID_SET + : getWorktreeIdsWithLiveAgent( + useAppStore.getState().agentStatusByPaneKey, + tabsByWorktree, + Date.now() + ), + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, + alwaysShowDefaultBranchWorkspace, + repoMap, + workspaceHostScope, + visibleWorkspaceHostIds, + defaultHostId: getSettingsFocusedExecutionHostId(settings), + worktreeLineageById, + forcedVisibleWorktreeIds: args.agentSendTargetWorktreeId + ? [args.agentSendTargetWorktreeId] + : undefined + }) + return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null) + }, [ + args.agentSendTargetWorktreeId, + agentStatusEpoch, + filterRepoIds, + showSleepingWorkspaces, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace, + workspaceHostScope, + visibleWorkspaceHostIds, + settings, + repoMap, + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + sortedIds, + worktreeMap, + worktreeLineageById, + worktreesByRepo, + pairedDeviceIdsByEnvironment + ]) + // Why: agentStatusEpoch bumps recompute this memo even when membership and + // order are unchanged; keeping the previous identity stops the whole + // rows/sectionRows/renderedWorktrees chain from churning per epoch. + const visibleWorktrees = useReusedArrayIdentity(recomputedVisibleWorktrees) + + return { visibleWorktrees, pairedDeviceIdsByEnvironment } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-workspace-status-row-drag.ts b/src/renderer/src/components/sidebar/worktree-list/use-workspace-status-row-drag.ts new file mode 100644 index 00000000000..cb45d99a93f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-workspace-status-row-drag.ts @@ -0,0 +1,147 @@ +import { useCallback, useMemo } from 'react' +import type React from 'react' +import type { WorkspaceStatus } from '../../../../../shared/worktree/types' +import type { HostSectionRow } from '../host-section-rows' +import { PINNED_GROUP_KEY, type WorktreeGroupBy } from '../worktree-list-groups' +import { hasWorkspaceDragData, readWorkspaceDragDataIds } from '../workspace-status' +import { useWorkspaceStatusDocumentDrop } from '../use-workspace-status-drop' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' + +// Drag-and-drop onto a status section header or a status-grouped row, including the +// document-level fallback used when the pointer leaves the sidebar mid-drag. +export function useWorkspaceStatusRowDrag(args: { + ctx: WorktreeDropCommitContext + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + rows: HostSectionRow[] + groupBy: WorktreeGroupBy + onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void + onPinWorktree: (worktreeId: string) => void +}) { + const { ctx, session, runtime, scrollRef, rows, groupBy } = args + const { setDragOverStatus, setPinDragOver, clearWorktreeDrag } = runtime + + const hasWorkspaceDropTargets = useMemo( + () => + groupBy === 'workspace-status' || + rows.some((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY), + [groupBy, rows] + ) + + const handleWorkspaceStatusDragOver = useCallback( + (event: React.DragEvent, status: WorkspaceStatus) => { + if (!hasWorkspaceDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setDragOverStatus(status) + }, + [setDragOverStatus] + ) + + const handleWorkspaceStatusDragLeave = useCallback( + (event: React.DragEvent) => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return + } + setDragOverStatus(null) + }, + [setDragOverStatus] + ) + + const handleWorkspacePinDragOver = useCallback( + (event: React.DragEvent) => { + if (!hasWorkspaceDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setPinDragOver(true) + }, + [setPinDragOver] + ) + + const handleWorkspacePinDragLeave = useCallback( + (event: React.DragEvent) => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return + } + setPinDragOver(false) + }, + [setPinDragOver] + ) + + const handleWorkspaceStatusDragFinish = useCallback(() => { + setDragOverStatus(null) + setPinDragOver(false) + }, [setDragOverStatus, setPinDragOver]) + + const handleWorkspaceStatusDrop = useCallback( + (event: React.DragEvent, status: WorkspaceStatus) => { + const worktreeIds = readWorkspaceDragDataIds(event.dataTransfer) + if (worktreeIds.length === 0) { + return + } + event.preventDefault() + const dragSession = session.worktreeDragSessionRef.current + const statusDrop = dragSession + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status, + draggedIds: dragSession.reorderDraggedIds + }) + : null + setDragOverStatus(null) + if (dragSession && statusDrop) { + event.stopPropagation() + ctx.onMoveWorktreesToStatusAtIndex({ + worktreeIds: dragSession.reorderDraggedIds, + status, + dropIndex: statusDrop.dropIndex, + groups: ctx.worktreeDragGroups + }) + clearWorktreeDrag() + return + } + // Match status-drop scope to drag-preview scope (#9083): session uses its expanded set, else expand dataTransfer ids live. + ctx.onMoveWorktreesToStatus( + dragSession ? dragSession.reorderDraggedIds : session.getReorderDraggedIds(worktreeIds), + status + ) + }, + [clearWorktreeDrag, ctx, session, setDragOverStatus] + ) + + // Why: expand here (not the shared hook, used by the flat board) so a dropped parent carries its lineage children (#9083). + const moveWorktreesToStatusForDocumentDrop = useCallback( + (ids: readonly string[], status: WorkspaceStatus) => + ctx.onMoveWorktreesToStatus(session.getReorderDraggedIds(ids), status), + [ctx, session] + ) + + useWorkspaceStatusDocumentDrop( + scrollRef, + args.onMoveWorktreeToStatus, + args.onPinWorktree, + handleWorkspaceStatusDragFinish, + hasWorkspaceDropTargets, + { + onMoveWorktreesToStatus: moveWorktreesToStatusForDocumentDrop, + onPinWorktrees: ctx.onPinWorktrees + } + ) + + return { + handleWorkspaceStatusDragOver, + handleWorkspaceStatusDragLeave, + handleWorkspacePinDragOver, + handleWorkspacePinDragLeave, + handleWorkspaceStatusDrop + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-document-drop.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-document-drop.ts new file mode 100644 index 00000000000..7902f0647cb --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-document-drop.ts @@ -0,0 +1,115 @@ +import { useEffect } from 'react' +import type React from 'react' +import { getFullDropIndexForWorktreeDragUnit } from '../worktree-drag-units' +import type { WorktreeSidebarDragSession } from '../worktree-sidebar-drag-autoscroll' +import { getPointerDropStatusTarget } from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import { NO_WORKTREE_SIDEBAR_DROP_TARGET } from './worktree-row-drag-state' + +// Why: a drop that lands outside the sidebar still belongs to the in-flight session, so the +// capture-phase document listeners commit or abandon it before anything else sees the event. +export function useWorktreeDocumentDrop(args: { + ctx: WorktreeDropCommitContext + worktreeDragSessionRef: React.MutableRefObject +}): void { + const { ctx, worktreeDragSessionRef } = args + const { clearWorktreeDrag } = ctx + + useEffect(() => { + const handleDocumentDrop = (event: DragEvent): void => { + const session = worktreeDragSessionRef.current + if (!session) { + return + } + if (!ctx.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + const drop = ctx.computeWorktreeDrop(event.clientY) + if (!drop) { + const container = ctx.scrollRef.current + const target = ctx.getEligibleLineageDropTarget( + container + ? getPointerDropStatusTarget({ + container, + x: event.clientX, + y: event.clientY + }) + : NO_WORKTREE_SIDEBAR_DROP_TARGET, + session.draggedIds + ) + if (target.lineageParentId) { + event.preventDefault() + event.stopPropagation() + ctx.commitWorktreeLineageParentDrop(session.draggedIds, target.lineageParentId) + clearWorktreeDrag() + return + } + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: target.status, + draggedIds: session.reorderDraggedIds + }) + : null + if (target.status && statusDrop) { + event.preventDefault() + event.stopPropagation() + ctx.onMoveWorktreesToStatusAtIndex({ + worktreeIds: session.reorderDraggedIds, + status: target.status, + dropIndex: statusDrop.dropIndex, + groups: ctx.worktreeDragGroups + }) + clearWorktreeDrag() + return + } + clearWorktreeDrag() + return + } + // Why: pointer still inside the source group means reorder, not status move; commit here and stop the capture handler. + event.preventDefault() + event.stopPropagation() + ctx.onReorderWorktrees({ + groups: ctx.worktreeDragGroups, + sourceGroupKey: session.sourceGroupKey, + draggedIds: session.reorderDraggedIds, + dropIndex: getFullDropIndexForWorktreeDragUnit({ + groups: ctx.worktreeDragUnitGroups, + sourceGroupKey: session.sourceGroupKey, + dropIndex: drop.dropIndex + }) + }) + ctx.clearReorderedWorktreeParents({ + draggedIds: session.draggedIds, + sourceGroupKey: session.sourceGroupKey + }) + clearWorktreeDrag() + } + + document.addEventListener('drop', handleDocumentDrop, true) + return () => document.removeEventListener('drop', handleDocumentDrop, true) + }, [clearWorktreeDrag, ctx, worktreeDragSessionRef]) + + useEffect(() => { + const handleDocumentDragEnd = (): void => { + if (worktreeDragSessionRef.current) { + clearWorktreeDrag() + } + } + + document.addEventListener('dragend', handleDocumentDragEnd, true) + return () => document.removeEventListener('dragend', handleDocumentDragEnd, true) + }, [clearWorktreeDrag, worktreeDragSessionRef]) + + useEffect(() => { + const handleVisibilityChange = (): void => { + if (document.visibilityState !== 'visible' && worktreeDragSessionRef.current) { + clearWorktreeDrag() + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => document.removeEventListener('visibilitychange', handleVisibilityChange) + }, [clearWorktreeDrag, worktreeDragSessionRef]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-runtime.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-runtime.ts new file mode 100644 index 00000000000..978c31c5cc3 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-runtime.ts @@ -0,0 +1,123 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import type React from 'react' +import type { WorkspaceStatus } from '../../../../../shared/worktree/types' +import { clearWorkspaceKanbanSidebarDropTargetVisual } from '../workspace-kanban-sidebar-drop' +import { setSidebarPointerDragDocumentStyles } from '../worktree-sidebar-pointer-drag-dom' +import type { + WorktreeSidebarDragPoint, + WorktreeSidebarDragSession +} from '../worktree-sidebar-drag-autoscroll' +import type { WorktreeSidebarDropAnchor } from '../worktree-sidebar-drag-geometry' +import { + WORKTREE_ROW_DRAG_INITIAL_STATE, + type WorktreePointerDrag, + type WorktreeRowDragState +} from './worktree-row-drag-state' + +export type WorktreeDragRuntime = ReturnType + +// Owns everything a drag leaves behind: the visual state the rows read, the RAF handles, +// and the single teardown both the pointer and native drag paths call. +export function useWorktreeDragRuntime(args: { + worktreeDragSessionRef: React.MutableRefObject + statusDropAnchorsRef: React.MutableRefObject> + onWorkspaceBoardDragPreviewCancel: () => void +}) { + const { worktreeDragSessionRef, statusDropAnchorsRef, onWorkspaceBoardDragPreviewCancel } = args + const [dragOverStatus, setDragOverStatus] = useState(null) + const [pinDragOver, setPinDragOver] = useState(false) + const [nativeLineageDropTargetId, setNativeLineageDropTargetId] = useState(null) + const [worktreeDragState, setWorktreeDragState] = useState( + WORKTREE_ROW_DRAG_INITIAL_STATE + ) + const worktreePointerDragRef = useRef(null) + const pointerAutoscrollFrameIdRef = useRef(null) + const pointerAutoscrollLastFrameTimeRef = useRef(null) + const nativeAutoscrollFrameIdRef = useRef(null) + const nativeAutoscrollLastFrameTimeRef = useRef(null) + const nativeLatestPointRef = useRef(null) + const suppressWorktreeClickUntilRef = useRef(0) + + const cancelWorktreePointerAutoscroll = useCallback(() => { + if (pointerAutoscrollFrameIdRef.current !== null) { + window.cancelAnimationFrame(pointerAutoscrollFrameIdRef.current) + pointerAutoscrollFrameIdRef.current = null + } + pointerAutoscrollLastFrameTimeRef.current = null + }, []) + + const cancelWorktreeNativeAutoscroll = useCallback(() => { + if (nativeAutoscrollFrameIdRef.current !== null) { + window.cancelAnimationFrame(nativeAutoscrollFrameIdRef.current) + nativeAutoscrollFrameIdRef.current = null + } + nativeAutoscrollLastFrameTimeRef.current = null + nativeLatestPointRef.current = null + }, []) + + const cleanupWorktreePointerDrag = useCallback(() => { + const drag = worktreePointerDragRef.current + cancelWorktreePointerAutoscroll() + setNativeLineageDropTargetId(null) + if (!drag) { + return + } + if (drag.frameId !== null) { + window.cancelAnimationFrame(drag.frameId) + } + drag.preview?.remove() + worktreePointerDragRef.current = null + setSidebarPointerDragDocumentStyles(false) + setDragOverStatus(null) + setPinDragOver(false) + clearWorkspaceKanbanSidebarDropTargetVisual() + onWorkspaceBoardDragPreviewCancel() + }, [cancelWorktreePointerAutoscroll, onWorkspaceBoardDragPreviewCancel]) + + const clearWorktreeDrag = useCallback(() => { + cleanupWorktreePointerDrag() + cancelWorktreeNativeAutoscroll() + worktreeDragSessionRef.current = null + statusDropAnchorsRef.current.clear() + setWorktreeDragState(WORKTREE_ROW_DRAG_INITIAL_STATE) + }, [ + cancelWorktreeNativeAutoscroll, + cleanupWorktreePointerDrag, + statusDropAnchorsRef, + worktreeDragSessionRef + ]) + + // Why: drag handlers built from this object end up on memoised cards; keep one identity + // per meaningful change instead of one per render. + return useMemo( + () => ({ + dragOverStatus, + setDragOverStatus, + pinDragOver, + setPinDragOver, + nativeLineageDropTargetId, + setNativeLineageDropTargetId, + worktreeDragState, + setWorktreeDragState, + worktreePointerDragRef, + pointerAutoscrollFrameIdRef, + pointerAutoscrollLastFrameTimeRef, + nativeAutoscrollFrameIdRef, + nativeAutoscrollLastFrameTimeRef, + nativeLatestPointRef, + suppressWorktreeClickUntilRef, + cancelWorktreePointerAutoscroll, + cancelWorktreeNativeAutoscroll, + clearWorktreeDrag + }), + [ + cancelWorktreeNativeAutoscroll, + cancelWorktreePointerAutoscroll, + clearWorktreeDrag, + dragOverStatus, + nativeLineageDropTargetId, + pinDragOver, + worktreeDragState + ] + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-session.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-session.ts new file mode 100644 index 00000000000..f1077c71ff0 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-session.ts @@ -0,0 +1,244 @@ +import { useCallback, useMemo, useRef } from 'react' +import type React from 'react' +import type { WorkspaceStatus } from '../../../../../shared/worktree/types' +import type { HostSectionRow } from '../host-section-rows' +import { PINNED_GROUP_KEY } from '../worktree-list-groups' +import { WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP } from '../worktree-list-virtual-rows' +import { getWorkspaceStatusGroupKey } from '../workspace-status' +import { expandDraggedWorktreeIdsForVisibleLineage } from '../worktree-manual-order' +import { getWorktreeDragUnitGroups } from '../worktree-drag-units' +import { + getWorktreeSidebarDragRectsForGroup, + refreshWorktreeSidebarDragSession, + type WorktreeSidebarDragRect, + type WorktreeSidebarDragSession +} from '../worktree-sidebar-drag-autoscroll' +import { + shouldReevaluateWorktreeSidebarDropAnchor, + type WorktreeSidebarDragGrab, + type WorktreeSidebarDropAnchor +} from '../worktree-sidebar-drag-geometry' +import { + computeWorktreeSidebarDropPreview, + type WorktreeSidebarDropPreview +} from '../worktree-sidebar-drop-preview' +import { getWorktreeDragGroups, getWorktreeDragIndexes } from './worktree-drag-groups' +import type { WorktreeItemRow } from './render-row-item-rows' + +export type WorktreeStatusDropRequest = { + pointerY: number + status: WorkspaceStatus + draggedIds: readonly string[] +} + +export type WorktreeDragSession = ReturnType + +// Owns the geometry side of a sidebar row drag: which groups exist, which ids travel +// together, and where the insertion line lands for a given pointer position. +export function useWorktreeDragSession(args: { + rows: HostSectionRow[] + scrollRef: React.RefObject +}) { + const { rows, scrollRef } = args + const worktreeDragSessionRef = useRef(null) + // Why: cross-group hovers hit-test a group the session never captured, so hold + // that group's drop decision separately or a card expanding in the target group + // moves the insertion line under a still pointer. + const statusDropAnchorsRef = useRef>(new Map()) + + const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows]) + const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows]) + const naturalDragWorktreeIds = useMemo( + () => + new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ), + [rows] + ) + const worktreeLineageDragRows = useMemo( + () => + rows + .filter((row): row is WorktreeItemRow => row.type === 'item') + .filter( + (row) => + row.sectionKey !== PINNED_GROUP_KEY || !naturalDragWorktreeIds.has(row.worktree.id) + ) + .map((row) => ({ worktreeId: row.worktree.id, depth: row.depth })), + [naturalDragWorktreeIds, rows] + ) + const getReorderDraggedIds = useCallback( + (draggedIds: readonly string[]) => + expandDraggedWorktreeIdsForVisibleLineage(worktreeLineageDragRows, draggedIds), + [worktreeLineageDragRows] + ) + const getReorderUnitDraggedIds = useCallback( + (sourceGroupKey: string, reorderDraggedIds: readonly string[]) => { + const group = worktreeDragUnitGroups.find((candidate) => candidate.key === sourceGroupKey) + if (!group) { + return reorderDraggedIds + } + const unitIds = new Set(group.worktreeIds) + const filtered = reorderDraggedIds.filter((worktreeId) => unitIds.has(worktreeId)) + return filtered.length > 0 ? filtered : reorderDraggedIds + }, + [worktreeDragUnitGroups] + ) + const { groupKeyByRowKey, groupIndexByRowKey } = useMemo( + () => getWorktreeDragIndexes(rows), + [rows] + ) + const refreshWorktreeDragSession = useCallback((): boolean => { + const session = worktreeDragSessionRef.current + const container = scrollRef.current + if (!session || !container) { + return false + } + + const refreshedSession = refreshWorktreeSidebarDragSession({ + session, + groups: worktreeDragGroups, + unitGroups: worktreeDragUnitGroups, + rects: getWorktreeSidebarDragRectsForGroup(container, session.sourceGroupKey) + }) + worktreeDragSessionRef.current = refreshedSession + return refreshedSession !== null + }, [scrollRef, worktreeDragGroups, worktreeDragUnitGroups]) + const computeWorktreeDropForGroup = useCallback( + (dropArgs: { + pointerY: number + groupKey: string + rects: readonly WorktreeSidebarDragRect[] + draggedIds: readonly string[] + draggingWorktreeId?: string | null + grab?: WorktreeSidebarDragGrab | null + anchor?: WorktreeSidebarDropAnchor | null + }): WorktreeSidebarDropPreview | null => { + const container = scrollRef.current + if (!container) { + return null + } + const group = worktreeDragUnitGroups.find((candidate) => candidate.key === dropArgs.groupKey) + if (!group) { + return null + } + const containerRect = container.getBoundingClientRect() + return computeWorktreeSidebarDropPreview({ + pointerY: dropArgs.pointerY, + containerTop: containerRect.top, + scrollTop: container.scrollTop, + rects: dropArgs.rects, + groupIds: group.worktreeIds, + draggedIds: dropArgs.draggedIds, + draggingWorktreeId: dropArgs.draggingWorktreeId, + fallbackGap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, + grab: dropArgs.grab, + anchor: dropArgs.anchor + }) + }, + [scrollRef, worktreeDragUnitGroups] + ) + const computeWorktreeDrop = useCallback( + (pointerY: number): WorktreeSidebarDropPreview | null => { + const session = worktreeDragSessionRef.current + const container = scrollRef.current + if (!session || !container) { + return null + } + const scrollTop = container.scrollTop + // Why: only real pointer or scroll movement should re-decide the slot; a + // card growing under a still pointer must not move it. + const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ + anchor: session.anchor, + pointerY, + scrollTop + }) + ? null + : session.anchor + const preview = computeWorktreeDropForGroup({ + pointerY, + groupKey: session.sourceGroupKey, + rects: session.rects, + draggedIds: session.reorderUnitDraggedIds, + draggingWorktreeId: session.draggingWorktreeId, + grab: session.grab, + anchor + }) + worktreeDragSessionRef.current = { + ...session, + anchor: preview ? { beforeWorktreeId: preview.dropAnchorId, pointerY, scrollTop } : null + } + return preview + }, + [computeWorktreeDropForGroup, scrollRef] + ) + const computeWorktreeStatusDrop = useCallback( + (request: WorktreeStatusDropRequest): WorktreeSidebarDropPreview | null => { + const container = scrollRef.current + if (!container) { + return null + } + const groupKey = getWorkspaceStatusGroupKey(request.status) + const session = worktreeDragSessionRef.current + const scrollTop = container.scrollTop + const heldAnchor = statusDropAnchorsRef.current.get(groupKey) ?? null + const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ + anchor: heldAnchor, + pointerY: request.pointerY, + scrollTop + }) + ? null + : heldAnchor + const preview = computeWorktreeDropForGroup({ + pointerY: request.pointerY, + groupKey, + rects: getWorktreeSidebarDragRectsForGroup(container, groupKey), + draggedIds: request.draggedIds, + draggingWorktreeId: session?.draggingWorktreeId ?? null, + grab: session?.grab ?? null, + anchor + }) + if (preview) { + statusDropAnchorsRef.current.set(groupKey, { + beforeWorktreeId: preview.dropAnchorId, + pointerY: request.pointerY, + scrollTop + }) + } else { + statusDropAnchorsRef.current.delete(groupKey) + } + return preview + }, + [computeWorktreeDropForGroup, scrollRef] + ) + + // Why: consumers pass session-derived callbacks into memoised cards; a fresh object every + // render would defeat those bail-outs. + return useMemo( + () => ({ + worktreeDragSessionRef, + statusDropAnchorsRef, + worktreeDragGroups, + worktreeDragUnitGroups, + groupKeyByRowKey, + groupIndexByRowKey, + getReorderDraggedIds, + getReorderUnitDraggedIds, + refreshWorktreeDragSession, + computeWorktreeDrop, + computeWorktreeStatusDrop + }), + [ + computeWorktreeDrop, + computeWorktreeStatusDrop, + getReorderDraggedIds, + getReorderUnitDraggedIds, + groupIndexByRowKey, + groupKeyByRowKey, + refreshWorktreeDragSession, + worktreeDragGroups, + worktreeDragUnitGroups + ] + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-lineage-drop-commit.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-lineage-drop-commit.ts new file mode 100644 index 00000000000..6629fe526e1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-lineage-drop-commit.ts @@ -0,0 +1,127 @@ +import { useCallback, useMemo } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import type { Repo } from '../../../../../shared/repo-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types' +import { isEligibleWorktreeParent } from '../worktree-parent-candidates' +import { getCyclicProjectedWorktreeLineageIds } from '../worktree-lineage-projection' +import { getReorderedWorktreeIdsToUnnest } from '../worktree-lineage-drag-drop' +import type { WorktreeDragGroup } from '../worktree-manual-order' +import type { WorktreeSidebarLineageDropTarget } from './worktree-row-drag-state' + +export type WorktreeLineageDropCommit = ReturnType + +// Nesting and un-nesting are the two lineage mutations a sidebar drag can commit. +export function useWorktreeLineageDropCommit(args: { + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + worktreeDragGroups: readonly WorktreeDragGroup[] +}) { + const { repoMap, worktreeMap, worktreeLineageById, worktreeDragGroups } = args + const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) + const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) + const cyclicLineageIds = useMemo( + () => getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap), + [worktreeLineageById, worktreeMap] + ) + + const getEligibleLineageDropTarget = useCallback( + ( + target: WorktreeSidebarLineageDropTarget, + draggedIds: readonly string[] + ): WorktreeSidebarLineageDropTarget => { + const parentId = target.lineageParentId + if (!parentId) { + return target + } + const canAssignAll = draggedIds.every((draggedId) => { + const child = worktreeMap.get(draggedId) + if (!child) { + return false + } + const candidateParent = worktreeMap.get(parentId) + return Boolean( + candidateParent && + isEligibleWorktreeParent({ + child, + candidateParent, + lineageById: worktreeLineageById, + worktreeMap, + repoMap, + cyclicLineageIds + }) + ) + }) + return canAssignAll ? target : { ...target, lineageParentId: null } + }, + [cyclicLineageIds, repoMap, worktreeLineageById, worktreeMap] + ) + + const commitWorktreeLineageParentDrop = useCallback( + (draggedIds: readonly string[], parentId: string): boolean => { + const target = getEligibleLineageDropTarget( + { status: null, isPinDrop: false, lineageParentId: parentId }, + draggedIds + ) + if (!target.lineageParentId) { + return false + } + void Promise.all( + draggedIds.map((id) => assignWorktreeParent(id, { parentWorktreeId: parentId })) + ).catch((err) => { + console.error('Failed to nest workspace:', err) + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.failedNestWorkspace', + 'Failed to nest workspace' + ) + ) + }) + return true + }, + [assignWorktreeParent, getEligibleLineageDropTarget] + ) + + const clearReorderedWorktreeParents = useCallback( + (unnestArgs: { draggedIds: readonly string[]; sourceGroupKey: string }) => { + const sourceGroup = worktreeDragGroups.find( + (group) => group.key === unnestArgs.sourceGroupKey + ) + if (!sourceGroup) { + return + } + const ids = getReorderedWorktreeIdsToUnnest({ + draggedIds: unnestArgs.draggedIds, + sourceGroupIds: sourceGroup.worktreeIds, + lineageById: worktreeLineageById, + worktreeMap, + cyclicLineageIds + }) + if (ids.length === 0) { + return + } + // Why: dropping a nested card on a reorder line is the un-nest escape hatch; clear only the dragged children. + void Promise.all(ids.map((id) => updateWorktreeLineage(id, { noParent: true }))).catch( + (err) => { + console.error('Failed to unnest workspace:', err) + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.failedUnnestWorkspace', + 'Failed to unnest workspace' + ) + ) + } + ) + }, + [cyclicLineageIds, updateWorktreeLineage, worktreeDragGroups, worktreeLineageById, worktreeMap] + ) + + return { + getEligibleLineageDropTarget, + commitWorktreeLineageParentDrop, + clearReorderedWorktreeParents + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-keyboard-navigation.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-keyboard-navigation.ts new file mode 100644 index 00000000000..4a9fc419703 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-keyboard-navigation.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect } from 'react' +import type React from 'react' +import type { Virtualizer } from '@tanstack/react-virtual' +import { useAppStore } from '@/store' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { getShortcutPlatform } from '@/lib/shortcut-platform' +import { keybindingMatchesAction } from '../../../../../shared/keybindings' +import type { HostSectionRow } from '../host-section-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import type { RenderRow } from '../worktree-list-virtual-rows' +import { getCyclableWorktreeIds, resolveCycledWorktreeId } from '../worktree-keyboard-cycle' +import { findPreferredRenderRowIndexForWorktree } from './render-row-worktree-lookup' +import { isEditableTarget } from './sidebar-editable-target' + +export function useWorktreeListKeyboardNavigation(args: { + rows: HostSectionRow[] + renderRows: RenderRow[] + activeWorktreeId: string | null + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + virtualizer: Virtualizer + scrollRef: React.RefObject + activeModal: string + markDirectScrollInput: () => void +}) { + const { + rows, + renderRows, + activeWorktreeId, + pinnedDisplayPolicy, + virtualizer, + scrollRef, + activeModal, + markDirectScrollInput + } = args + const keybindings = useAppStore((s) => s.keybindings) + + const navigateWorktree = useCallback( + (direction: 'up' | 'down') => { + // Why: cycle over the rows the sidebar actually rendered — collapsing a group + // means "not now", and a rebuilt near-copy would drift from what is on screen + // (host sections, pinned placement, folder workspaces). + const nextWorktreeId = resolveCycledWorktreeId({ + worktreeIds: getCyclableWorktreeIds(rows, pinnedDisplayPolicy), + activeWorktreeId, + direction + }) + if (nextWorktreeId === null) { + return + } + + // Why: keyboard cycling is real navigation; route through the activation helper that records history. + activateAndRevealWorktree(nextWorktreeId) + + const rowIndex = findPreferredRenderRowIndexForWorktree( + renderRows, + nextWorktreeId, + pinnedDisplayPolicy + ) + if (rowIndex !== -1) { + virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) + } + }, + [rows, renderRows, activeWorktreeId, virtualizer, pinnedDisplayPolicy] + ) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (activeModal !== 'none' || isEditableTarget(e.target)) { + return + } + + const platform = getShortcutPlatform() + if (keybindingMatchesAction('sidebar.focusWorktreeList', e, platform, keybindings)) { + scrollRef.current?.focus() + e.preventDefault() + return + } + + const direction = keybindingMatchesAction('worktree.navigateUp', e, platform, keybindings) + ? 'up' + : keybindingMatchesAction('worktree.navigateDown', e, platform, keybindings) + ? 'down' + : null + if (direction) { + markDirectScrollInput() + navigateWorktree(direction) + e.preventDefault() + } + } + + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, [activeModal, keybindings, markDirectScrollInput, navigateWorktree, scrollRef]) + + const handleContainerKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + if (e.target !== e.currentTarget) { + return + } + markDirectScrollInput() + navigateWorktree(e.key === 'ArrowUp' ? 'up' : 'down') + e.preventDefault() + } else if (e.key === 'Enter') { + const helper = document.querySelector( + '.xterm-helper-textarea' + ) as HTMLTextAreaElement | null + if (helper) { + helper.focus() + } + e.preventDefault() + } else if (['PageUp', 'PageDown', 'Home', 'End', ' '].includes(e.key)) { + markDirectScrollInput() + } + }, + [markDirectScrollInput, navigateWorktree] + ) + + return { handleContainerKeyDown } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-virtualizer.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-virtualizer.ts new file mode 100644 index 00000000000..52bd92358ce --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-virtualizer.ts @@ -0,0 +1,159 @@ +import { useCallback, useMemo, useRef } from 'react' +import type React from 'react' +import { + measureElement as measureVirtualElementSize, + useVirtualizer +} from '@tanstack/react-virtual' +import type { Range } from '@tanstack/react-virtual' +import { + estimateRenderRowSize, + extractWorktreeVirtualRowIndexes, + getRenderRowKey, + getStickyHeaderIndexes, + WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, + type RenderRow +} from '../worktree-list-virtual-rows' +import { WORKTREE_SIDEBAR_REVEAL_TOP_INSET } from '../worktree-sidebar-reveal' +import { getVirtualRowIndex } from './virtual-row-dom-attributes' +import { + shouldAdjustWorktreeSidebarMeasuredRowScroll, + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS +} from './worktree-sidebar-scroll-tuning' + +export type WorktreeListVirtualizer = ReturnType + +// The TanStack virtualizer plus the row-identity guards that stop a recycled DOM node +// from writing a stale height into the wrong slot. +export function useWorktreeListVirtualizer(args: { + renderRows: RenderRow[] + firstHeaderIndex: number + scrollRef: React.RefObject + scrollOffsetRef: React.MutableRefObject + suppressMeasurementAdjustmentUntilRef: React.MutableRefObject +}) { + const { renderRows, firstHeaderIndex, scrollRef, scrollOffsetRef } = args + const renderRowsRef = useRef(renderRows) + renderRowsRef.current = renderRows + const firstHeaderIndexRef = useRef(firstHeaderIndex) + firstHeaderIndexRef.current = firstHeaderIndex + const stickyHeaderIndexes = useMemo(() => getStickyHeaderIndexes(renderRows), [renderRows]) + const activeStickyHeaderIndexRef = useRef(null) + const activeStickyHostIndexRef = useRef(null) + const stickyRangeStartIndexRef = useRef(0) + + const getVirtualItemKey = useCallback( + (index: number) => { + const row = renderRows[index] + if (!row) { + return `__stale_${index}` + } + return getRenderRowKey(row) + }, + [renderRows] + ) + const getExpectedVirtualRowKey = useCallback((element: Element) => { + const index = getVirtualRowIndex(element) + const row = index === null ? undefined : renderRowsRef.current[index] + return row ? getRenderRowKey(row) : null + }, []) + const isCurrentVirtualRowElement = useCallback( + (element: Element) => { + const expectedKey = getExpectedVirtualRowKey(element) + return ( + element.isConnected && + expectedKey !== null && + element.getAttribute('data-worktree-virtual-row-key') === expectedKey + ) + }, + [getExpectedVirtualRowKey] + ) + const measureCurrentVirtualRowElement = useCallback( + ( + element: HTMLDivElement, + entry: ResizeObserverEntry | undefined, + instance: Parameters>[2] + ) => { + if (!isCurrentVirtualRowElement(element)) { + const index = getVirtualRowIndex(element) + const measured = instance.getVirtualItems().find((item) => item.index === index) + // Why: a stale ResizeObserver row after remount would write a wrong height; return current size to no-op it. + return ( + measured?.size ?? + estimateRenderRowSize( + renderRowsRef.current, + index ?? -1, + firstHeaderIndexRef.current, + activeStickyHeaderIndexRef.current + ) + ) + } + const index = getVirtualRowIndex(element) + if ( + index !== null && + (renderRowsRef.current[index]?.type === 'header' || + renderRowsRef.current[index]?.type === 'host-header') + ) { + return estimateRenderRowSize( + renderRowsRef.current, + index, + firstHeaderIndexRef.current, + activeStickyHeaderIndexRef.current + ) + } + return measureVirtualElementSize(element, entry, instance) + }, + [isCurrentVirtualRowElement] + ) + + const virtualizer = useVirtualizer({ + count: renderRows.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => + estimateRenderRowSize( + renderRows, + index, + firstHeaderIndex, + activeStickyHeaderIndexRef.current + ), + measureElement: measureCurrentVirtualRowElement, + // Why: TanStack memoizes rangeExtractor by identity; header indexes must be deps or sticky slots go stale. + rangeExtractor: useCallback( + (range: Range) => { + stickyRangeStartIndexRef.current = range.startIndex + return extractWorktreeVirtualRowIndexes({ + range, + stickyHeaderIndexes, + rows: renderRowsRef.current + }) + }, + [stickyHeaderIndexes] + ), + overscan: 10, + gap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, + // Why: the sticky group header lives inside the virtual list, so scroll math needs the same top inset as the DOM reveal. + scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET, + isScrollingResetDelay: USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS, + // Why: sync-flushing rich card renders in the scroll listener stalls wheel input; async + overscan keeps rows filled. + useFlushSync: false, + // Why: seed scrollOffset from the ref (not 0) so the first getVirtualItems() after remount picks the right rows. + initialOffset: () => scrollOffsetRef.current, + getItemKey: getVirtualItemKey + }) + // Why: TanStack's default correction writes scrollTop while cards remeasure mid-wheel, which feels like rubber-banding. + // TODO(scroll-origin-migration): wall-clock suppression misclassifies under jank; migrate to programmaticScrollMarks + restoreSignal (see CombinedDiffViewer). + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => + shouldAdjustWorktreeSidebarMeasuredRowScroll({ + isScrolling: instance.isScrolling, + now: window.performance.now(), + suppressUntil: args.suppressMeasurementAdjustmentUntilRef.current + }) + + return { + virtualizer, + isCurrentVirtualRowElement, + stickyHeaderIndexes, + activeStickyHeaderIndexRef, + activeStickyHostIndexRef, + stickyRangeStartIndexRef + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag-autoscroll.ts new file mode 100644 index 00000000000..339f1f8b768 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag-autoscroll.ts @@ -0,0 +1,108 @@ +import { useCallback } from 'react' +import type React from 'react' +import { getWorktreeSidebarDragAutoscroll } from '../worktree-sidebar-drag-autoscroll' +import { getPointerDropStatusTarget } from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import { applyWorktreeDropPreview, clearWorktreeDropPreview } from './worktree-row-drag-state' + +// The HTML5 drag equivalent of pointer autoscroll: the browser gives no move events while +// hovering the edge, so re-derive the drop preview from the last known point each frame. +export function useWorktreeNativeDragAutoscroll(args: { + ctx: WorktreeDropCommitContext + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + markScrollMovement: () => void +}): () => void { + const { ctx, session, runtime, scrollRef, markScrollMovement } = args + const { + nativeAutoscrollFrameIdRef, + nativeAutoscrollLastFrameTimeRef, + nativeLatestPointRef, + cancelWorktreeNativeAutoscroll, + clearWorktreeDrag, + setWorktreeDragState + } = runtime + + const runFrame = useCallback( + (frameTime: number) => { + nativeAutoscrollFrameIdRef.current = null + const point = nativeLatestPointRef.current + const container = scrollRef.current + const dragSession = session.worktreeDragSessionRef.current + if (!point || !container || !dragSession) { + cancelWorktreeNativeAutoscroll() + return + } + + const previousFrameTime = nativeAutoscrollLastFrameTimeRef.current ?? frameTime + nativeAutoscrollLastFrameTimeRef.current = frameTime + const autoscroll = getWorktreeSidebarDragAutoscroll({ + point, + containerRect: container.getBoundingClientRect(), + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + elapsedMs: frameTime - previousFrameTime + }) + if (autoscroll) { + markScrollMovement() + container.scrollTop = autoscroll.scrollTop + if (!session.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + const drop = ctx.computeWorktreeDrop(point.clientY) + if (!drop) { + const target = getPointerDropStatusTarget({ + container, + x: point.clientX, + y: point.clientY + }) + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: point.clientY, + status: target.status, + draggedIds: dragSession.reorderDraggedIds + }) + : null + if (statusDrop) { + setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, statusDrop, { pointerY: point.clientY }) + ) + return + } + setWorktreeDragState((prev) => clearWorktreeDropPreview(prev, { pointerY: null })) + } else { + setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, drop, { pointerY: point.clientY }) + ) + } + } + + nativeAutoscrollFrameIdRef.current = window.requestAnimationFrame(runFrame) + }, + [ + cancelWorktreeNativeAutoscroll, + clearWorktreeDrag, + ctx, + markScrollMovement, + nativeAutoscrollFrameIdRef, + nativeAutoscrollLastFrameTimeRef, + nativeLatestPointRef, + scrollRef, + session, + setWorktreeDragState + ] + ) + + return useCallback(() => { + if (nativeAutoscrollFrameIdRef.current !== null) { + return + } + nativeAutoscrollLastFrameTimeRef.current = null + nativeAutoscrollFrameIdRef.current = window.requestAnimationFrame(runFrame) + }, [nativeAutoscrollFrameIdRef, nativeAutoscrollLastFrameTimeRef, runFrame]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag.ts new file mode 100644 index 00000000000..b7fc693326e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag.ts @@ -0,0 +1,231 @@ +import { useCallback } from 'react' +import type React from 'react' +import { getWorkspaceKanbanSidebarDropTarget } from '../workspace-kanban-sidebar-drop' +import { getFullDropIndexForWorktreeDragUnit } from '../worktree-drag-units' +import { getWorktreeSidebarDragRectsForGroup } from '../worktree-sidebar-drag-autoscroll' +import { getWorktreeSidebarDragGrab } from '../worktree-sidebar-drag-geometry' +import { getPointerDropStatusTarget } from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import { useWorktreeNativeDragAutoscroll } from './use-worktree-native-drag-autoscroll' +import { + applyWorktreeDropPreview, + clearWorktreeDropPreview, + EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + NO_WORKTREE_SIDEBAR_DROP_TARGET +} from './worktree-row-drag-state' + +// The HTML5 drag path used by cards that start a native dragstart (board <-> sidebar transfers). +export function useWorktreeNativeDrag(args: { + ctx: WorktreeDropCommitContext + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + markScrollMovement: () => void +}) { + const { ctx, session, runtime, scrollRef, markScrollMovement } = args + const { + nativeLatestPointRef, + clearWorktreeDrag, + setWorktreeDragState, + setNativeLineageDropTargetId + } = runtime + + const startWorktreeNativeAutoscroll = useWorktreeNativeDragAutoscroll({ + ctx, + session, + runtime, + scrollRef, + markScrollMovement + }) + + const handleWorktreeCardDragStart = useCallback( + (event: React.DragEvent, worktreeId: string, draggedIds: readonly string[]) => { + const sourceGroupKey = + ctx.worktreeDragGroups.find((group) => group.worktreeIds.includes(worktreeId))?.key ?? null + if (!sourceGroupKey) { + return + } + const reorderDraggedIds = session.getReorderDraggedIds(draggedIds) + const reorderUnitDraggedIds = session.getReorderUnitDraggedIds( + sourceGroupKey, + reorderDraggedIds + ) + const rects = scrollRef.current + ? getWorktreeSidebarDragRectsForGroup(scrollRef.current, sourceGroupKey) + : [] + const sourceRect = event.currentTarget.getBoundingClientRect() + session.worktreeDragSessionRef.current = { + draggingWorktreeId: worktreeId, + sourceGroupKey, + draggedIds, + reorderDraggedIds, + reorderUnitDraggedIds, + rects, + grab: getWorktreeSidebarDragGrab({ + offsetY: event.clientY - sourceRect.top, + height: sourceRect.height + }), + anchor: null + } + setWorktreeDragState({ + draggingWorktreeId: worktreeId, + sourceGroupKey, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: null + }) + }, + [ctx.worktreeDragGroups, scrollRef, session, setWorktreeDragState] + ) + + const handleWorktreeDragOver = useCallback( + (event: React.DragEvent) => { + const dragSession = session.worktreeDragSessionRef.current + if (!dragSession) { + return + } + nativeLatestPointRef.current = { clientX: event.clientX, clientY: event.clientY } + startWorktreeNativeAutoscroll() + if (!session.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + const target = ctx.getEligibleLineageDropTarget( + getPointerDropStatusTarget({ + container: event.currentTarget, + x: event.clientX, + y: event.clientY + }), + dragSession.draggedIds + ) + if (target.lineageParentId) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setNativeLineageDropTargetId(target.lineageParentId) + setWorktreeDragState((prev) => clearWorktreeDropPreview(prev, { pointerY: event.clientY })) + return + } + setNativeLineageDropTargetId(null) + + const drop = ctx.computeWorktreeDrop(event.clientY) + if (!drop) { + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: target.status, + draggedIds: dragSession.reorderDraggedIds + }) + : null + if (statusDrop) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, statusDrop, { pointerY: event.clientY }) + ) + return + } + setWorktreeDragState((prev) => clearWorktreeDropPreview(prev, { pointerY: null })) + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, drop, { pointerY: event.clientY }) + ) + }, + [ + clearWorktreeDrag, + ctx, + nativeLatestPointRef, + session, + setNativeLineageDropTargetId, + setWorktreeDragState, + startWorktreeNativeAutoscroll + ] + ) + + const handleWorktreeDrop = useCallback( + (event: React.DragEvent) => { + const dragSession = session.worktreeDragSessionRef.current + if (!dragSession) { + return + } + if (!session.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + const boardDropTarget = getWorkspaceKanbanSidebarDropTarget(event.clientX, event.clientY) + if (boardDropTarget.status || boardDropTarget.isPinDrop) { + clearWorktreeDrag() + return + } + + const container = scrollRef.current + const target = ctx.getEligibleLineageDropTarget( + container + ? getPointerDropStatusTarget({ + container, + x: event.clientX, + y: event.clientY + }) + : NO_WORKTREE_SIDEBAR_DROP_TARGET, + dragSession.draggedIds + ) + + if (target.lineageParentId) { + event.preventDefault() + event.stopPropagation() + ctx.commitWorktreeLineageParentDrop(dragSession.draggedIds, target.lineageParentId) + clearWorktreeDrag() + return + } + + const drop = ctx.computeWorktreeDrop(event.clientY) + if (!drop) { + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: target.status, + draggedIds: dragSession.reorderDraggedIds + }) + : null + if (target.status && statusDrop) { + event.preventDefault() + event.stopPropagation() + ctx.onMoveWorktreesToStatusAtIndex({ + worktreeIds: dragSession.reorderDraggedIds, + status: target.status, + dropIndex: statusDrop.dropIndex, + groups: ctx.worktreeDragGroups + }) + clearWorktreeDrag() + return + } + clearWorktreeDrag() + return + } + event.preventDefault() + ctx.onReorderWorktrees({ + groups: ctx.worktreeDragGroups, + sourceGroupKey: dragSession.sourceGroupKey, + draggedIds: dragSession.reorderDraggedIds, + dropIndex: getFullDropIndexForWorktreeDragUnit({ + groups: ctx.worktreeDragUnitGroups, + sourceGroupKey: dragSession.sourceGroupKey, + dropIndex: drop.dropIndex + }) + }) + ctx.clearReorderedWorktreeParents({ + draggedIds: dragSession.draggedIds, + sourceGroupKey: dragSession.sourceGroupKey + }) + clearWorktreeDrag() + }, + [clearWorktreeDrag, ctx, scrollRef, session] + ) + + return { handleWorktreeCardDragStart, handleWorktreeDragOver, handleWorktreeDrop } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-autoscroll.ts new file mode 100644 index 00000000000..a2af2bd422d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-autoscroll.ts @@ -0,0 +1,79 @@ +import { useCallback } from 'react' +import type React from 'react' +import { getWorktreeSidebarDragAutoscroll } from '../worktree-sidebar-drag-autoscroll' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import type { WorktreePointerDrag } from './worktree-row-drag-state' + +// Scrolls the sidebar while a pointer drag hovers near its top or bottom edge, refreshing +// the drag session on every scrolled frame so the insertion line stays on the right row. +export function useWorktreePointerDragAutoscroll(args: { + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + markScrollMovement: () => void + scheduleWorktreePointerDragFrame: (drag: WorktreePointerDrag) => void +}): () => void { + const { session, runtime, scrollRef, markScrollMovement, scheduleWorktreePointerDragFrame } = args + const { + worktreePointerDragRef, + pointerAutoscrollFrameIdRef, + pointerAutoscrollLastFrameTimeRef, + cancelWorktreePointerAutoscroll, + clearWorktreeDrag + } = runtime + + const runFrame = useCallback( + (frameTime: number) => { + pointerAutoscrollFrameIdRef.current = null + const drag = worktreePointerDragRef.current + const container = scrollRef.current + const dragSession = session.worktreeDragSessionRef.current + if (!drag?.active || !container || !dragSession) { + cancelWorktreePointerAutoscroll() + return + } + + const previousFrameTime = pointerAutoscrollLastFrameTimeRef.current ?? frameTime + pointerAutoscrollLastFrameTimeRef.current = frameTime + const autoscroll = getWorktreeSidebarDragAutoscroll({ + point: { clientX: drag.currentX, clientY: drag.currentY }, + containerRect: container.getBoundingClientRect(), + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + elapsedMs: frameTime - previousFrameTime + }) + if (autoscroll) { + markScrollMovement() + container.scrollTop = autoscroll.scrollTop + if (!session.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + scheduleWorktreePointerDragFrame(drag) + } + + pointerAutoscrollFrameIdRef.current = window.requestAnimationFrame(runFrame) + }, + [ + cancelWorktreePointerAutoscroll, + clearWorktreeDrag, + markScrollMovement, + pointerAutoscrollFrameIdRef, + pointerAutoscrollLastFrameTimeRef, + scheduleWorktreePointerDragFrame, + scrollRef, + session, + worktreePointerDragRef + ] + ) + + return useCallback(() => { + if (pointerAutoscrollFrameIdRef.current !== null) { + return + } + pointerAutoscrollLastFrameTimeRef.current = null + pointerAutoscrollFrameIdRef.current = window.requestAnimationFrame(runFrame) + }, [pointerAutoscrollFrameIdRef, pointerAutoscrollLastFrameTimeRef, runFrame]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-window-events.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-window-events.ts new file mode 100644 index 00000000000..745670dde4d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-window-events.ts @@ -0,0 +1,95 @@ +import { useEffect } from 'react' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' +import { commitWorktreePointerDrop } from './worktree-pointer-drag-commit' +import { SIDEBAR_POINTER_DRAG_THRESHOLD_PX } from './worktree-sidebar-scroll-tuning' +import type { WorktreePointerDrag } from './worktree-row-drag-state' + +// Pointer drags escape the row that started them, so move/up/cancel are tracked on the +// window in capture phase. +export function useWorktreePointerDragWindowEvents(args: { + ctx: WorktreeDropCommitContext + runtime: WorktreeDragRuntime + beginWorktreePointerDrag: (drag: WorktreePointerDrag) => void + scheduleWorktreePointerDragFrame: (drag: WorktreePointerDrag) => void + onWorkspaceBoardDragPreviewCommit: () => void + onDropWorktreesOnWorkspaceBoard: (dropArgs: WorktreeStatusDropAtIndexArgs) => void +}): void { + const { + ctx, + beginWorktreePointerDrag, + scheduleWorktreePointerDragFrame, + onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard + } = args + const { worktreePointerDragRef, clearWorktreeDrag } = args.runtime + + useEffect(() => { + const handlePointerMove = (event: PointerEvent): void => { + const drag = worktreePointerDragRef.current + if (!drag || event.pointerId !== drag.pointerId) { + return + } + drag.currentX = event.clientX + drag.currentY = event.clientY + if (!drag.active) { + const distance = Math.hypot(drag.currentX - drag.startX, drag.currentY - drag.startY) + if (distance < SIDEBAR_POINTER_DRAG_THRESHOLD_PX) { + return + } + beginWorktreePointerDrag(drag) + } + event.preventDefault() + event.stopPropagation() + scheduleWorktreePointerDragFrame(drag) + } + + const handlePointerUp = (event: PointerEvent): void => { + const drag = worktreePointerDragRef.current + if (!drag || event.pointerId !== drag.pointerId) { + return + } + drag.currentX = event.clientX + drag.currentY = event.clientY + if (!drag.active) { + worktreePointerDragRef.current = null + return + } + event.preventDefault() + event.stopPropagation() + commitWorktreePointerDrop({ + event, + drag, + ctx, + onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard + }) + } + + const handlePointerCancel = (event: PointerEvent): void => { + const drag = worktreePointerDragRef.current + if (!drag || event.pointerId !== drag.pointerId) { + return + } + clearWorktreeDrag() + } + + window.addEventListener('pointermove', handlePointerMove, { capture: true }) + window.addEventListener('pointerup', handlePointerUp, { capture: true }) + window.addEventListener('pointercancel', handlePointerCancel, { capture: true }) + return () => { + window.removeEventListener('pointermove', handlePointerMove, { capture: true }) + window.removeEventListener('pointerup', handlePointerUp, { capture: true }) + window.removeEventListener('pointercancel', handlePointerCancel, { capture: true }) + } + }, [ + beginWorktreePointerDrag, + clearWorktreeDrag, + ctx, + onDropWorktreesOnWorkspaceBoard, + onWorkspaceBoardDragPreviewCommit, + scheduleWorktreePointerDragFrame, + worktreePointerDragRef + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag.ts new file mode 100644 index 00000000000..b61694d9bd4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag.ts @@ -0,0 +1,259 @@ +import { useCallback, useEffect } from 'react' +import type React from 'react' +import type { WorkspaceStatus, Worktree } from '../../../../../shared/worktree/types' +import { hasWorkspaceKanbanSidebarDropBoard } from '../workspace-kanban-sidebar-drop' +import { + createSidebarDragPreview, + isSidebarPointerDragBlocked, + setSidebarPointerDragDocumentStyles +} from '../worktree-sidebar-pointer-drag-dom' +import { getWorktreeSidebarDragRectsForGroup } from '../worktree-sidebar-drag-autoscroll' +import { getWorktreeSidebarDragGrab } from '../worktree-sidebar-drag-geometry' +import { NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK } from './virtualized-worktree-viewport-props' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import { useWorktreePointerDragAutoscroll } from './use-worktree-pointer-drag-autoscroll' +import { useWorktreePointerDragWindowEvents } from './use-worktree-pointer-drag-window-events' +import { flushWorktreePointerDragFrame } from './worktree-pointer-drag-flush' +import { + EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + type WorktreePointerDrag +} from './worktree-row-drag-state' + +export function useWorktreePointerDrag(args: { + ctx: WorktreeDropCommitContext + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + markScrollMovement: () => void + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + workspaceBoardOpen: boolean + onWorkspaceBoardDragPreviewStart: () => void + onWorkspaceBoardDragPreviewCommit: () => void + onDropWorktreesOnWorkspaceBoard: (dropArgs: WorktreeStatusDropAtIndexArgs) => void + shouldShowWorkspaceBoardDropIndicator: ( + worktreeIds: readonly string[], + status: WorkspaceStatus + ) => boolean +}) { + const { + ctx, + session, + runtime, + scrollRef, + markScrollMovement, + selectedWorktreeIds, + selectedWorktrees, + workspaceBoardOpen, + onWorkspaceBoardDragPreviewStart, + onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard, + shouldShowWorkspaceBoardDropIndicator + } = args + const { + worktreePointerDragRef, + suppressWorktreeClickUntilRef, + setWorktreeDragState, + setDragOverStatus, + setPinDragOver + } = runtime + + const flushWorktreePointerDrag = useCallback(() => { + const drag = worktreePointerDragRef.current + if (!drag) { + return + } + flushWorktreePointerDragFrame({ + drag, + ctx, + workspaceBoardOpen, + onWorkspaceBoardDragPreviewStart, + onWorkspaceBoardDragPreviewCommit, + shouldShowWorkspaceBoardDropIndicator, + setWorktreeDragState, + setDragOverStatus, + setPinDragOver + }) + }, [ + ctx, + onWorkspaceBoardDragPreviewCommit, + onWorkspaceBoardDragPreviewStart, + setDragOverStatus, + setPinDragOver, + setWorktreeDragState, + shouldShowWorkspaceBoardDropIndicator, + workspaceBoardOpen, + worktreePointerDragRef + ]) + + const scheduleWorktreePointerDragFrame = useCallback( + (drag: WorktreePointerDrag) => { + if (drag.frameId !== null) { + return + } + drag.frameId = window.requestAnimationFrame(flushWorktreePointerDrag) + }, + [flushWorktreePointerDrag] + ) + + const startWorktreePointerAutoscroll = useWorktreePointerDragAutoscroll({ + session, + runtime, + scrollRef, + markScrollMovement, + scheduleWorktreePointerDragFrame + }) + + const beginWorktreePointerDrag = useCallback( + (drag: WorktreePointerDrag) => { + const { preview, offsetX, offsetY, height } = createSidebarDragPreview({ + sourceRow: drag.sourceRow, + pointerX: drag.currentX, + pointerY: drag.currentY, + draggedCount: drag.draggedIds.length + }) + drag.active = true + drag.preview = preview + drag.previewOffsetX = offsetX + drag.previewOffsetY = offsetY + suppressWorktreeClickUntilRef.current = window.performance.now() + 500 + setSidebarPointerDragDocumentStyles(true) + session.worktreeDragSessionRef.current = { + draggingWorktreeId: drag.worktreeId, + sourceGroupKey: drag.sourceGroupKey, + draggedIds: drag.draggedIds, + reorderDraggedIds: drag.reorderDraggedIds, + reorderUnitDraggedIds: drag.reorderUnitDraggedIds, + rects: drag.rects, + // Why: reuse the floating preview's own offset so the hit test tracks the + // card the user sees, not the raw pointer. + grab: getWorktreeSidebarDragGrab({ offsetY, height }), + anchor: null + } + setWorktreeDragState({ + draggingWorktreeId: drag.worktreeId, + sourceGroupKey: drag.sourceGroupKey, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: drag.currentY + }) + startWorktreePointerAutoscroll() + scheduleWorktreePointerDragFrame(drag) + }, + [ + scheduleWorktreePointerDragFrame, + session, + setWorktreeDragState, + startWorktreePointerAutoscroll, + suppressWorktreeClickUntilRef + ] + ) + + const handleWorktreeRowPointerDown = useCallback( + (event: React.PointerEvent, worktreeId: string, rowKey: string) => { + if (event.button !== 0 || event.pointerType === 'touch') { + return + } + const sourceRow = event.currentTarget + if (isSidebarPointerDragBlocked(event.target, sourceRow)) { + return + } + const sourceGroupKey = session.groupKeyByRowKey.get(rowKey) + const container = scrollRef.current + if (!sourceGroupKey || !container) { + return + } + const rects = getWorktreeSidebarDragRectsForGroup(container, sourceGroupKey) + const canPreviewWorkspaceBoardOnDrag = + !workspaceBoardOpen && + onWorkspaceBoardDragPreviewStart !== NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK + if ( + rects.length <= 1 && + !hasWorkspaceKanbanSidebarDropBoard() && + !canPreviewWorkspaceBoardOnDrag + ) { + return + } + const draggedIds = + selectedWorktreeIds.has(worktreeId) && selectedWorktrees.length > 1 + ? selectedWorktrees.map((worktree) => worktree.id) + : [worktreeId] + const reorderDraggedIds = session.getReorderDraggedIds(draggedIds) + const reorderUnitDraggedIds = session.getReorderUnitDraggedIds( + sourceGroupKey, + reorderDraggedIds + ) + worktreePointerDragRef.current = { + pointerId: event.pointerId, + sourceRow, + startX: event.clientX, + startY: event.clientY, + currentX: event.clientX, + currentY: event.clientY, + worktreeId, + draggedIds, + reorderDraggedIds, + reorderUnitDraggedIds, + sourceGroupKey, + rects, + active: false, + preview: null, + previewOffsetX: 0, + previewOffsetY: 0, + workspaceBoardDragPreviewRequested: false, + frameId: null, + latestBoardDropTarget: null, + latestStatusDropTarget: null + } + }, + [ + onWorkspaceBoardDragPreviewStart, + scrollRef, + selectedWorktreeIds, + selectedWorktrees, + session, + workspaceBoardOpen, + worktreePointerDragRef + ] + ) + + const handleWorktreeRowClickCapture = useCallback( + (event: React.MouseEvent) => { + if (window.performance.now() >= suppressWorktreeClickUntilRef.current) { + return + } + event.preventDefault() + event.stopPropagation() + }, + [suppressWorktreeClickUntilRef] + ) + + useWorktreePointerDragWindowEvents({ + ctx, + runtime, + beginWorktreePointerDrag, + scheduleWorktreePointerDragFrame, + onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard + }) + + useEffect(() => { + const handleClick = (event: MouseEvent): void => { + if (window.performance.now() >= suppressWorktreeClickUntilRef.current) { + return + } + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + } + + document.addEventListener('click', handleClick, true) + return () => document.removeEventListener('click', handleClick, true) + }, [suppressWorktreeClickUntilRef]) + + return { handleWorktreeRowPointerDown, handleWorktreeRowClickCapture } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-header-drag.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-header-drag.ts new file mode 100644 index 00000000000..93be311bc8e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-header-drag.ts @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useMemo } from 'react' +import type React from 'react' +import { useAppStore } from '@/store' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { ProjectOrderBy } from '../../../../../shared/ui-chrome-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import type { HostHeaderRow, HostSectionRow } from '../host-section-rows' +import type { Row, WorktreeGroupBy } from '../worktree-list-groups' +import type { RenderRow } from '../worktree-list-virtual-rows' +import { + getProjectGroupHeaderSectionEndByGroupId, + getRepoHeaderSectionEndByRepoId +} from '../worktree-header-section-boundaries' +import { useHostHeaderDrag } from '../host-header-drag' +import { useRepoHeaderDrag } from '../project-header-drag' +import { getSidebarOrderedRepoHeaderIdsByBucket } from '../project-header-drop' +import { useProjectGroupHeaderDrag } from '../project-group-header-drag' +import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from '../project-group-header-drop' +import { USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS } from './worktree-sidebar-scroll-tuning' + +export type WorktreeSidebarHeaderDrag = ReturnType + +function indexById(byBucket: ReadonlyMap): Map { + const map = new Map() + for (const ids of byBucket.values()) { + ids.forEach((id, index) => { + map.set(id, index) + }) + } + return map +} + +function bucketById(byBucket: ReadonlyMap): Map { + const map = new Map() + for (const [bucketKey, ids] of byBucket) { + for (const id of ids) { + map.set(id, bucketKey) + } + } + return map +} + +// Reordering the three header tiers (host card, project group, project) plus the bucket +// bookkeeping their drag controllers and DOM attributes need. +export function useWorktreeSidebarHeaderDrag(args: { + rows: HostSectionRow[] + renderRows: readonly RenderRow[] + firstHeaderIndex: number + allRepoIds: string[] + repoMap: Map + projectGroups: readonly ProjectGroup[] + groupBy: WorktreeGroupBy + projectOrderBy: ProjectOrderBy + scrollRef: React.RefObject + onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void + onHostDragActiveChange: (active: boolean) => void + suppressMeasurementAdjustmentUntilRef: React.MutableRefObject + directScrollInputUntilRef: React.MutableRefObject +}) { + const { + rows, + renderRows, + firstHeaderIndex, + allRepoIds, + repoMap, + projectGroups, + groupBy, + projectOrderBy, + scrollRef, + onReorderHostSections, + onHostDragActiveChange, + suppressMeasurementAdjustmentUntilRef, + directScrollInputUntilRef + } = args + const reorderRepos = useAppStore((s) => s.reorderRepos) + const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) + const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) + const hasProjectGroups = projectGroups.length > 0 + const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual' + const canReorderProjectGroupHeaders = groupBy === 'repo' && hasProjectGroups + const projectGroupByIdForHeaderDrag = useMemo( + () => new Map(projectGroups.map((group) => [group.id, group])), + [projectGroups] + ) + + // Why: reorder keeps scrollTop stable; flag direct scroll input so anchor-restore won't chase the moved row (jumpy drop). + const suppressScrollCorrectionForHeaderCommit = useCallback(() => { + const suppressUntil = window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + suppressMeasurementAdjustmentUntilRef.current = suppressUntil + directScrollInputUntilRef.current = suppressUntil + }, [directScrollInputUntilRef, suppressMeasurementAdjustmentUntilRef]) + const commitRepoReorder = useCallback( + (orderedIds: string[]) => { + suppressScrollCorrectionForHeaderCommit() + reorderRepos(orderedIds) + }, + [reorderRepos, suppressScrollCorrectionForHeaderCommit] + ) + const orderedHostIds = useMemo( + () => + rows + .filter((row): row is HostHeaderRow => row.type === 'host-header') + .map((row) => row.hostId), + [rows] + ) + const hostDrag = useHostHeaderDrag({ + orderedHostIds, + onCommit: onReorderHostSections, + getScrollContainer: () => scrollRef.current + }) + useEffect(() => { + onHostDragActiveChange(hostDrag.state.draggingHostId !== null) + }, [hostDrag.state.draggingHostId, onHostDragActiveChange]) + useEffect(() => () => onHostDragActiveChange(false), [onHostDragActiveChange]) + + const sidebarRepoHeaderIdsByBucket = useMemo( + () => + getSidebarOrderedRepoHeaderIdsByBucket( + rows.filter((row): row is Row => row.type !== 'host-header') + ), + [rows] + ) + const sidebarProjectGroupHeaderIdsByBucket = useMemo( + () => + getSidebarOrderedProjectGroupHeaderIdsByBucket( + rows.filter((row): row is Row => row.type !== 'host-header'), + projectGroupByIdForHeaderDrag + ), + [projectGroupByIdForHeaderDrag, rows] + ) + const repoHeaderIndexByRepoId = useMemo( + () => indexById(sidebarRepoHeaderIdsByBucket), + [sidebarRepoHeaderIdsByBucket] + ) + const repoHeaderBucketByRepoId = useMemo( + () => bucketById(sidebarRepoHeaderIdsByBucket), + [sidebarRepoHeaderIdsByBucket] + ) + const projectGroupHeaderIndexByGroupId = useMemo( + () => indexById(sidebarProjectGroupHeaderIdsByBucket), + [sidebarProjectGroupHeaderIdsByBucket] + ) + const projectGroupHeaderBucketByGroupId = useMemo( + () => bucketById(sidebarProjectGroupHeaderIdsByBucket), + [sidebarProjectGroupHeaderIdsByBucket] + ) + const commitProjectGroupOrder = useCallback( + (repoId: string, projectGroupId: string | null, order: number) => { + void moveProjectToGroup(repoId, projectGroupId, order) + }, + [moveProjectToGroup] + ) + const commitProjectGroupHeaderOrder = useCallback( + (groupId: string, tabOrder: number) => { + if (!Number.isFinite(tabOrder)) { + return + } + suppressScrollCorrectionForHeaderCommit() + void updateProjectGroup(groupId, { tabOrder }) + }, + [suppressScrollCorrectionForHeaderCommit, updateProjectGroup] + ) + // Drag applies only in manual order; still construct the controller inert for stable hook order. + const repoDrag = useRepoHeaderDrag({ + orderedRepoIds: allRepoIds, + sidebarRepoHeaderIdsByBucket, + repoById: repoMap, + usesProjectGroupOrdering: hasProjectGroups, + onCommitRepoOrder: commitRepoReorder, + onCommitProjectGroupOrder: commitProjectGroupOrder, + getScrollContainer: () => scrollRef.current + }) + const projectGroupDrag = useProjectGroupHeaderDrag({ + sidebarProjectGroupHeaderIdsByBucket, + projectGroupById: projectGroupByIdForHeaderDrag, + onCommitProjectGroupTabOrder: commitProjectGroupHeaderOrder, + getScrollContainer: () => scrollRef.current + }) + const repoHeaderSectionEndByRepoId = useMemo( + () => + getRepoHeaderSectionEndByRepoId({ + rows: renderRows, + firstHeaderIndex, + sidebarRepoHeaderIdsByBucket, + repoHeaderBucketByRepoId + }), + [firstHeaderIndex, renderRows, repoHeaderBucketByRepoId, sidebarRepoHeaderIdsByBucket] + ) + const projectGroupHeaderSectionEndByGroupId = useMemo( + () => + getProjectGroupHeaderSectionEndByGroupId({ + rows: renderRows, + firstHeaderIndex, + sidebarProjectGroupHeaderIdsByBucket, + projectGroupHeaderBucketByGroupId + }), + [ + firstHeaderIndex, + projectGroupHeaderBucketByGroupId, + renderRows, + sidebarProjectGroupHeaderIdsByBucket + ] + ) + + return { + canReorderRepoHeaders, + canReorderProjectGroupHeaders, + orderedHostIds, + hostDrag, + repoDrag, + projectGroupDrag, + sidebarRepoHeaderIdsByBucket, + sidebarProjectGroupHeaderIdsByBucket, + repoHeaderIndexByRepoId, + repoHeaderBucketByRepoId, + projectGroupHeaderIndexByGroupId, + projectGroupHeaderBucketByGroupId, + repoHeaderSectionEndByRepoId, + projectGroupHeaderSectionEndByGroupId + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-scroll-suppression.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-scroll-suppression.ts new file mode 100644 index 00000000000..e606c270a70 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-scroll-suppression.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef } from 'react' +import type React from 'react' +import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from '../WorktreeCardAgents' +import { + createPendingRevealScroll, + isRevealScrollSettling, + type PendingRevealScroll +} from '../worktree-sidebar-reveal-scroll-settle' +import { + EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS, + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS +} from './worktree-sidebar-scroll-tuning' + +export type WorktreeSidebarScrollSuppression = ReturnType< + typeof useWorktreeSidebarScrollSuppression +> + +// Tracks the two suppression windows the virtualizer honours: measurement correction +// (any scroll movement) and anchor restore (direct user input or a settling reveal). +export function useWorktreeSidebarScrollSuppression( + scrollRef: React.RefObject +) { + const suppressMeasurementAdjustmentUntilRef = useRef(0) + const directScrollInputUntilRef = useRef(0) + const pendingRevealScrollRef = useRef(null) + + const markScrollMovement = useCallback(() => { + suppressMeasurementAdjustmentUntilRef.current = + window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + }, []) + const markDirectScrollInput = useCallback(() => { + const suppressUntil = window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + suppressMeasurementAdjustmentUntilRef.current = suppressUntil + directScrollInputUntilRef.current = suppressUntil + }, []) + const hasDirectScrollInput = useCallback( + () => window.performance.now() < directScrollInputUntilRef.current, + [] + ) + const markRevealScroll = useCallback((targetTop: number) => { + pendingRevealScrollRef.current = createPendingRevealScroll(targetTop, window.performance.now()) + }, []) + const isRevealScrollSettlingNow = useCallback(() => { + const settling = isRevealScrollSettling({ + now: window.performance.now(), + pending: pendingRevealScrollRef.current, + scrollTop: scrollRef.current?.scrollTop ?? 0 + }) + if (!settling) { + pendingRevealScrollRef.current = null + } + return settling + }, [scrollRef]) + // Why: programmatic scrolls keep measurement correction quiet, but only direct input blocks anchor-restore retries. + // A reveal's smooth scroll is the exception: restoring the anchor mid-animation cancels it a few pixels in. + const shouldSkipScrollAnchorRestore = useCallback( + () => + window.performance.now() < directScrollInputUntilRef.current || isRevealScrollSettlingNow(), + [isRevealScrollSettlingNow] + ) + + useEffect(() => { + const handleSuppress = () => { + // Why: let an expanding agent row grow in place instead of TanStack compensating scrollTop. + suppressMeasurementAdjustmentUntilRef.current = + window.performance.now() + EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + } + window.addEventListener(SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT, handleSuppress) + return () => { + window.removeEventListener(SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT, handleSuppress) + } + }, []) + + return { + suppressMeasurementAdjustmentUntilRef, + directScrollInputUntilRef, + markScrollMovement, + markDirectScrollInput, + hasDirectScrollInput, + markRevealScroll, + shouldSkipScrollAnchorRestore + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-status-mutations.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-status-mutations.ts new file mode 100644 index 00000000000..77f9cc38b3f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-status-mutations.ts @@ -0,0 +1,201 @@ +import { useCallback } from 'react' +import { useAppStore } from '@/store' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition, + Worktree +} from '../../../../../shared/worktree/types' +import type { WorktreeMeta } from '../../../../../shared/worktree/meta-types' +import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../workspace-status' +import { + buildManualOrderUpdatesForGroupDrop, + buildManualOrderUpdatesForVisibleGroups, + shouldWriteManualOrderForGroupDrop, + type WorktreeDragGroup +} from '../worktree-manual-order' +import { buildWorkspaceKanbanSidebarDropUpdates } from '../workspace-kanban-sidebar-drop' +import type { SortBy } from '../smart-sort' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' + +function buildRankByWorktreeId( + groups: readonly WorktreeDragGroup[], + worktreeMap: Map +): Map { + const rankByWorktreeId = new Map() + for (const group of groups) { + for (const worktreeId of group.worktreeIds) { + const worktree = worktreeMap.get(worktreeId) + if (worktree) { + rankByWorktreeId.set(worktreeId, worktree.manualOrder ?? worktree.sortOrder) + } + } + } + return rankByWorktreeId +} + +// Every write a sidebar drop can make: status changes, pin, manual order, and the board lane drop. +export function useWorktreeStatusMutations(args: { + worktreeMap: Map + workspaceStatuses: readonly WorkspaceStatusDefinition[] + sortBy: SortBy +}) { + const { worktreeMap, workspaceStatuses, sortBy } = args + const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) + const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta) + const setSortBy = useAppStore((s) => s.setSortBy) + const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal) + + const moveWorktreeToStatus = useCallback( + (worktreeId: string, status: WorkspaceStatus) => { + const current = worktreeMap.get(worktreeId) + if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { + return + } + void updateWorktreeMeta(worktreeId, { workspaceStatus: status }) + }, + [updateWorktreeMeta, worktreeMap, workspaceStatuses] + ) + + const moveWorktreesToStatus = useCallback( + (worktreeIds: readonly string[], status: WorkspaceStatus) => { + const updates = new Map() + for (const worktreeId of worktreeIds) { + const current = worktreeMap.get(worktreeId) + if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { + continue + } + updates.set(worktreeId, { workspaceStatus: status }) + } + if (updates.size > 0) { + void updateWorktreesMeta(updates) + } + }, + [updateWorktreesMeta, worktreeMap, workspaceStatuses] + ) + + const moveWorktreesToStatusAtIndex = useCallback( + (dropArgs: WorktreeStatusDropAtIndexArgs) => { + const order = buildManualOrderUpdatesForGroupDrop({ + groups: dropArgs.groups, + targetGroupKey: getWorkspaceStatusGroupKey(dropArgs.status), + draggedIds: dropArgs.worktreeIds, + dropIndex: dropArgs.dropIndex, + now: Date.now(), + rankByWorktreeId: buildRankByWorktreeId(dropArgs.groups, worktreeMap) + }) + const updates = new Map>() + for (const worktreeId of dropArgs.worktreeIds) { + const current = worktreeMap.get(worktreeId) + if (!current) { + continue + } + const next: Partial = {} + if (getWorkspaceStatus(current, workspaceStatuses) !== dropArgs.status) { + next.workspaceStatus = dropArgs.status + } + updates.set(worktreeId, next) + } + for (const [worktreeId, manualOrder] of order.updates) { + updates.set(worktreeId, { ...updates.get(worktreeId), ...manualOrder }) + } + for (const [worktreeId, update] of Array.from(updates)) { + if (Object.keys(update).length === 0) { + updates.delete(worktreeId) + } + } + if (updates.size === 0) { + return + } + // Why: the insertion line promises exact placement, so persist manual order on a cross-status drop. + if (order.changed) { + setSortBy('manual') + } + void updateWorktreesMeta(updates) + }, + [setSortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] + ) + + const pinWorktree = useCallback( + (worktreeId: string) => { + setWorktreesPinnedAndReveal([worktreeId], true) + }, + [setWorktreesPinnedAndReveal] + ) + + const pinWorktrees = useCallback( + (worktreeIds: readonly string[]) => { + setWorktreesPinnedAndReveal(worktreeIds, true) + }, + [setWorktreesPinnedAndReveal] + ) + + const reorderWorktrees = useCallback( + (reorderArgs: { + groups: readonly WorktreeDragGroup[] + sourceGroupKey: string + draggedIds: readonly string[] + dropIndex: number + }) => { + const result = buildManualOrderUpdatesForVisibleGroups({ + ...reorderArgs, + now: Date.now(), + rankByWorktreeId: buildRankByWorktreeId(reorderArgs.groups, worktreeMap) + }) + if (!result.changed) { + return + } + // Why: only switch to Manual after a real move so accidental click-drags don't change the sort. + setSortBy('manual') + void updateWorktreesMeta(result.updates) + }, + [setSortBy, updateWorktreesMeta, worktreeMap] + ) + + const shouldShowWorkspaceBoardDropIndicator = useCallback( + (worktreeIds: readonly string[], status: WorkspaceStatus) => { + const sourceGroupKeys = worktreeIds.flatMap((worktreeId) => { + const worktree = worktreeMap.get(worktreeId) + return worktree ? [getWorkspaceStatus(worktree, workspaceStatuses)] : [] + }) + return shouldWriteManualOrderForGroupDrop({ + sortBy, + sourceGroupKeys, + targetGroupKey: status + }) + }, + [sortBy, worktreeMap, workspaceStatuses] + ) + + const dropWorktreesOnWorkspaceBoard = useCallback( + (dropArgs: WorktreeStatusDropAtIndexArgs) => { + const result = buildWorkspaceKanbanSidebarDropUpdates({ + ...dropArgs, + worktreeById: worktreeMap, + workspaceStatuses, + sortBy, + now: Date.now() + }) + if (result.updates.size === 0) { + return + } + // Why: switch to Manual when the drop changes order so the placement stays visible. + if (result.shouldSwitchToManual) { + setSortBy('manual') + } + useAppStore.getState().recordFeatureInteraction('workspace-board-actions') + void updateWorktreesMeta(result.updates) + }, + [setSortBy, sortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] + ) + + return { + moveWorktreeToStatus, + moveWorktreesToStatus, + moveWorktreesToStatusAtIndex, + pinWorktree, + pinWorktrees, + reorderWorktrees, + shouldShowWorkspaceBoardDropIndicator, + dropWorktreesOnWorkspaceBoard + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/virtual-row-dom-attributes.ts b/src/renderer/src/components/sidebar/worktree-list/virtual-row-dom-attributes.ts new file mode 100644 index 00000000000..7523418e7c1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/virtual-row-dom-attributes.ts @@ -0,0 +1,15 @@ +import { getVirtualRowTransform } from '../worktree-list-virtual-rows' + +export function getVirtualRowIndex(element: Element): number | null { + const index = Number.parseInt(element.getAttribute('data-index') ?? '', 10) + return Number.isNaN(index) ? null : index +} + +export function getVirtualRowKey(element: Element): string | null { + return element.getAttribute('data-worktree-virtual-row-key') +} + +export function getWorktreeVirtualRowTransform(start: number, previewOffset: number): string { + const base = getVirtualRowTransform(start) + return previewOffset === 0 ? base : `${base} translateY(${previewOffset}px)` +} diff --git a/src/renderer/src/components/sidebar/worktree-list/virtualized-worktree-viewport-props.ts b/src/renderer/src/components/sidebar/worktree-list/virtualized-worktree-viewport-props.ts new file mode 100644 index 00000000000..2aaacf85795 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/virtualized-worktree-viewport-props.ts @@ -0,0 +1,116 @@ +import type React from 'react' +import type { AppState } from '@/store/types' +import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' +import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' +import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { ProjectOrderBy } from '../../../../../shared/ui-chrome-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { + WorkspaceLineage, + WorktreeLineage +} from '../../../../../shared/worktree/lineage-types' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition, + Worktree +} from '../../../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import type { HostSectionRow } from '../host-section-rows' +import type { ImportedWorktreeCardActionState } from '../imported-worktrees-card-actions' +import type { NewExternalWorktreesInboxActionState } from '../new-external-worktrees-inbox-actions' +import type { WorktreeDragGroup } from '../worktree-manual-order' +import type { + PinnedWorktreeDisplayPolicy, + ProjectGroupingModel, + WorktreeGroupBy +} from '../worktree-list-groups' + +export const EMPTY_PROJECT_GROUPS: readonly ProjectGroup[] = [] +export const NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK = (): void => {} + +export type WorktreeStatusDropAtIndexArgs = { + worktreeIds: readonly string[] + status: WorkspaceStatus + dropIndex: number + groups: readonly WorktreeDragGroup[] +} + +export type VirtualizedWorktreeViewportProps = { + rows: HostSectionRow[] + activeWorktreeId: string | null + currentWorktreeId: string | null + groupBy: WorktreeGroupBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + projectOrderBy: ProjectOrderBy + toggleGroup: (key: string) => void + collapsedGroups: Set + handleCreateForRepo: (projectId: string) => void + handleOpenRepoSettings: (projectId: string, sectionId?: string) => void + handleOpenWorktreeVisibility: (repo: Repo) => void + handleShowImportedWorktrees: (projectId: string) => void + handleKeepImportedWorktreesHidden: (projectId: string) => void + importedWorktreeCardActionState: ReadonlyMap + handleOpenSuppressExternalWorktreeInbox: (projectId: string) => void + newExternalWorktreeInboxActionState: ReadonlyMap + handleRemoveProject: (repo: Repo) => void + handleCreateGroupFromRepo: (repo: Repo) => void + handleMoveProjectToGroup: (repo: Repo, groupId: string) => void + handleRemoveProjectFromGroup: (repo: Repo) => void + handleRenameProjectGroup: (groupId: string, currentName: string) => void + handleDeleteProjectGroup: (groupId: string, groupName: string) => void + handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void + activeModal: string + pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null + clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void + agentSendTargetWorktreeId: string | null + worktrees: Worktree[] + folderWorkspaces: readonly FolderWorkspace[] + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onImmediateWorktreeActivate: (worktreeId: string, rowKey: string | undefined) => void + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + repoMap: Map + defaultHostId: ExecutionHostId + worktreeMap: Map + worktreeLineageById: Record + workspaceLineageByChildKey: Record + // Full canonical repo-id order; must include hidden repos or a reorder silently drops them. + allRepoIds: string[] + onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void + onHostDragActiveChange: (active: boolean) => void + prCache: AppState['prCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null + workspaceStatuses: readonly WorkspaceStatusDefinition[] + projectGrouping?: ProjectGroupingModel + projectGroups?: readonly ProjectGroup[] + onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void + onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void + onMoveWorktreesToStatusAtIndex: (args: WorktreeStatusDropAtIndexArgs) => void + onPinWorktree: (worktreeId: string) => void + onPinWorktrees: (worktreeIds: readonly string[]) => void + onDropWorktreesOnWorkspaceBoard: (args: WorktreeStatusDropAtIndexArgs) => void + workspaceBoardOpen: boolean + onWorkspaceBoardDragPreviewStart: () => void + onWorkspaceBoardDragPreviewCommit: () => void + onWorkspaceBoardDragPreviewCancel: () => void + shouldShowWorkspaceBoardDropIndicator: ( + worktreeIds: readonly string[], + status: WorkspaceStatus + ) => boolean + onReorderWorktrees: (args: { + groups: readonly WorktreeDragGroup[] + sourceGroupKey: string + draggedIds: readonly string[] + dropIndex: number + }) => void + // Why: grouping remounts the viewport, add/delete stays mounted; bridge both so the virtualizer never resets to scrollTop 0. + scrollOffsetRef: React.MutableRefObject + scrollAnchorRef: React.MutableRefObject +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-drag-groups.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-drag-groups.ts new file mode 100644 index 00000000000..eb93ecf617a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-drag-groups.ts @@ -0,0 +1,72 @@ +import { ALL_GROUP_KEY, PINNED_GROUP_KEY } from '../worktree-list-groups' +import type { HostSectionRow } from '../host-section-rows' +import type { WorktreeDragGroup } from '../worktree-manual-order' + +// A pinned duplicate of a worktree that also renders in its natural group is not its own drag slot. +function getNaturalWorktreeIds(rows: readonly HostSectionRow[]): Set { + return new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ) +} + +export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { + const groups: WorktreeDragGroup[] = [] + let current: { key: string; ids: string[] } | null = null + const naturalWorktreeIds = getNaturalWorktreeIds(rows) + + for (const row of rows) { + if (row.type === 'header') { + current = { key: row.key, ids: [] } + groups.push({ key: current.key, worktreeIds: current.ids }) + continue + } + if ( + row.type === 'host-header' || + row.type === 'imported-worktrees-card' || + row.type === 'new-external-worktrees-inbox' || + row.type === 'pending-creation' || + row.type === 'folder-workspace' + ) { + continue + } + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { + continue + } + if (!current) { + current = { key: ALL_GROUP_KEY, ids: [] } + groups.push({ key: current.key, worktreeIds: current.ids }) + } + current.ids.push(row.worktree.id) + } + + return groups.filter((group) => group.worktreeIds.length > 0) +} + +export function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): { + groupKeyByRowKey: Map + groupIndexByRowKey: Map +} { + const groupKeyByRowKey = new Map() + const groupIndexByRowKey = new Map() + const groupIndexes = new Map() + const naturalWorktreeIds = getNaturalWorktreeIds(rows) + for (const row of rows) { + if (row.type === 'header') { + groupIndexes.set(row.key, 0) + continue + } + if (row.type !== 'item') { + continue + } + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { + continue + } + const index = groupIndexes.get(row.sectionKey) ?? 0 + groupKeyByRowKey.set(row.rowKey, row.sectionKey) + groupIndexByRowKey.set(row.rowKey, index) + groupIndexes.set(row.sectionKey, index + 1) + } + return { groupKeyByRowKey, groupIndexByRowKey } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-drop-commit-context.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-drop-commit-context.ts new file mode 100644 index 00000000000..2fee58ba4a4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-drop-commit-context.ts @@ -0,0 +1,43 @@ +import type React from 'react' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition +} from '../../../../../shared/worktree/types' +import type { WorktreeDragGroup } from '../worktree-manual-order' +import type { WorktreeDragUnitGroup } from '../worktree-drag-units' +import type { WorktreeSidebarDropPreview } from '../worktree-sidebar-drop-preview' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' +import type { WorktreeStatusDropRequest } from './use-worktree-drag-session' +import type { WorktreeSidebarLineageDropTarget } from './worktree-row-drag-state' + +// The shared surface every drop path (pointer, native drag, document capture) commits through. +export type WorktreeDropCommitContext = { + scrollRef: React.RefObject + workspaceStatuses: readonly WorkspaceStatusDefinition[] + worktreeDragGroups: readonly WorktreeDragGroup[] + worktreeDragUnitGroups: readonly WorktreeDragUnitGroup[] + computeWorktreeDrop: (pointerY: number) => WorktreeSidebarDropPreview | null + computeWorktreeStatusDrop: ( + request: WorktreeStatusDropRequest + ) => WorktreeSidebarDropPreview | null + refreshWorktreeDragSession: () => boolean + getEligibleLineageDropTarget: ( + target: WorktreeSidebarLineageDropTarget, + draggedIds: readonly string[] + ) => WorktreeSidebarLineageDropTarget + commitWorktreeLineageParentDrop: (draggedIds: readonly string[], parentId: string) => boolean + clearReorderedWorktreeParents: (args: { + draggedIds: readonly string[] + sourceGroupKey: string + }) => void + clearWorktreeDrag: () => void + onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void + onMoveWorktreesToStatusAtIndex: (args: WorktreeStatusDropAtIndexArgs) => void + onReorderWorktrees: (args: { + groups: readonly WorktreeDragGroup[] + sourceGroupKey: string + draggedIds: readonly string[] + dropIndex: number + }) => void + onPinWorktrees: (worktreeIds: readonly string[]) => void +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-list-item-row.tsx b/src/renderer/src/components/sidebar/worktree-list/worktree-list-item-row.tsx new file mode 100644 index 00000000000..8e0a5c070c5 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-list-item-row.tsx @@ -0,0 +1,251 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import type { AppState } from '@/store/types' +import type { Worktree } from '../../../../../shared/worktree/types' +import WorktreeCard, { type ActiveSurfaceVariant } from '../WorktreeCard' +import { PINNED_GROUP_KEY, type WorktreeGroupBy } from '../worktree-list-groups' +import { + getFolderBackedRepoWorktreeCardContentIndent, + getFolderBackedRepoWorktreeCardSurfaceInset, + getLineageChildrenInlineStyle, + getLineageNestedRowGeometry, + getWorktreeCardContentIndent, + getWorktreeCardSurfaceInset, + LINEAGE_CHILDREN_INLINE_OFFSET +} from '../worktree-list-indentation' +import type { LineageToggleHandler } from '../worktree-lineage-toggle-handler-cache' +import { stopNestedWorktreeCardBubble } from './repo-header-event-guards' +import type { WorktreeItemRow } from './render-row-item-rows' +import { getWorktreeOptionId } from './worktree-option-dom' +import type { WorktreePointerDrag, WorktreeRowDragState } from './worktree-row-drag-state' + +export type WorktreeItemRowContext = { + settings: AppState['settings'] + groupBy: WorktreeGroupBy + folderBackedProjectGroupIds: ReadonlySet + groupKeyByRowKey: ReadonlyMap + groupIndexByRowKey: ReadonlyMap + agentSendTargetWorktreeId: string | null + worktreeDragState: WorktreeRowDragState + worktreePointerDragRef: React.MutableRefObject + nativeLineageDropTargetId: string | null + activeWorktreeId: string | null + currentWorktreeId: string | null + highlightedRevealRowKey: string | null + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + getActiveSurfaceVariant: (row: WorktreeItemRow) => ActiveSurfaceVariant + getLineageToggleHandler: (groupKey: string) => LineageToggleHandler + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + onImmediateActivate: (worktreeId: string, rowKey: string | undefined) => void + onRowClickCapture: (event: React.MouseEvent) => void + onRowPointerDown: ( + event: React.PointerEvent, + worktreeId: string, + rowKey: string + ) => void + onCardDragStart: ( + event: React.DragEvent, + worktreeId: string, + draggedIds: readonly string[] + ) => void + onCardDragEnd: () => void +} + +// Geometry differs three ways: a plain grouped row, a lineage child inheriting its parent's +// surface, and a row inside a folder-backed project group. +function getWorktreeItemRowGeometry( + ctx: WorktreeItemRowContext, + itemRow: WorktreeItemRow, + nested: boolean +): { surfaceInset: number; cardContentIndent: number; lineageChildrenInlineOffset?: number } { + const projectGroupId = itemRow.repo?.projectGroupId + const isFolderBackedRepoChild = + ctx.groupBy === 'repo' && + Boolean(projectGroupId && ctx.folderBackedProjectGroupIds.has(projectGroupId)) + // Why: experimental in-card lineage inherits the parent surface; legacy cards keep depth-based nested geometry. + const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth + const getCardContentIndent = (lineageDepth: number): number => + isFolderBackedRepoChild + ? getFolderBackedRepoWorktreeCardContentIndent({ + groupDepth: itemRow.groupDepth, + lineageDepth + }) + : getWorktreeCardContentIndent({ + isGrouped: ctx.groupBy !== 'none', + groupDepth: itemRow.groupDepth, + lineageDepth + }) + const nestedLineageGeometry = nested + ? getLineageNestedRowGeometry({ + experimentalNewWorktreeCardStyle: ctx.settings?.experimentalNewWorktreeCardStyle === true, + inheritedCardContentIndent: getCardContentIndent(0), + lineageDepth: itemRow.depth + }) + : null + // Why: grouped rows inherit their header depth, but the card surface still spans the full row. + const paddingLeft = + nested && ctx.groupBy !== 'none' + ? getWorktreeCardContentIndent({ + isGrouped: false, + groupDepth: itemRow.groupDepth, + lineageDepth: paddingDepth + }) + : getCardContentIndent(paddingDepth) + const surfaceInset = nestedLineageGeometry + ? nestedLineageGeometry.surfaceInset + : isFolderBackedRepoChild + ? getFolderBackedRepoWorktreeCardSurfaceInset({ + groupDepth: itemRow.groupDepth, + lineageDepth: paddingDepth + }) + : getWorktreeCardSurfaceInset({ + isGrouped: ctx.groupBy !== 'none', + groupDepth: itemRow.groupDepth + }) + return { + surfaceInset, + cardContentIndent: nestedLineageGeometry + ? nestedLineageGeometry.cardContentIndent + : Math.max(0, paddingLeft - surfaceInset), + lineageChildrenInlineOffset: nestedLineageGeometry?.lineageChildrenInlineOffset + } +} + +export function renderWorktreeItemRow( + ctx: WorktreeItemRowContext, + itemRow: WorktreeItemRow, + nested: boolean, + lineageChildren?: React.ReactNode, + forceActiveSurface = false +): React.JSX.Element { + const { surfaceInset, cardContentIndent, lineageChildrenInlineOffset } = + getWorktreeItemRowGeometry(ctx, itemRow, nested) + const lineageChildrenStyle = lineageChildren + ? getLineageChildrenInlineStyle(lineageChildrenInlineOffset ?? LINEAGE_CHILDREN_INLINE_OFFSET) + : undefined + const worktreeDragGroupKey = ctx.groupKeyByRowKey.get(itemRow.rowKey) + const isLineageDropTarget = + ctx.worktreeDragState.draggingWorktreeId && + (ctx.worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId === + itemRow.worktree.id || + ctx.nativeLineageDropTargetId === itemRow.worktree.id) + const isActiveWorktree = ctx.activeWorktreeId === itemRow.worktree.id + return ( +
{ + if (nested) { + event.stopPropagation() + } + ctx.onRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) + }} + style={{ + paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined + }} + > + +
+ ) +} + +// Rebuild the visible lineage subtree so each card renders its own children inline. +export function renderWorktreeLineageDescendants( + ctx: WorktreeItemRowContext, + parent: WorktreeItemRow, + descendants: readonly WorktreeItemRow[] +): React.ReactNode | undefined { + const childNodes: React.ReactNode[] = [] + let cursor = 0 + while (cursor < descendants.length) { + const child = descendants[cursor] + if (!child || child.depth !== parent.depth + 1) { + cursor++ + continue + } + + let nextSiblingIndex = cursor + 1 + while ( + nextSiblingIndex < descendants.length && + descendants[nextSiblingIndex]!.depth > child.depth + ) { + nextSiblingIndex++ + } + + const childLineageChildren = renderWorktreeLineageDescendants( + ctx, + child, + descendants.slice(cursor + 1, nextSiblingIndex) + ) + childNodes.push(renderWorktreeItemRow(ctx, child, true, childLineageChildren)) + cursor = nextSiblingIndex + } + return childNodes.length > 0 ? childNodes : undefined +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-notice-virtual-rows.tsx b/src/renderer/src/components/sidebar/worktree-list/worktree-notice-virtual-rows.tsx new file mode 100644 index 00000000000..d997548f4bd --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-notice-virtual-rows.tsx @@ -0,0 +1,116 @@ +import React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import type { Row } from '../worktree-list-groups' +import { getVirtualRowTransform } from '../worktree-list-virtual-rows' +import { PendingWorktreeRow } from '../PendingWorktreeRow' +import ImportedWorktreesVisibilityLine from '../ImportedWorktreesVisibilityLine' +import NewExternalWorktreesInboxLine from '../NewExternalWorktreesInboxLine' +import type { ImportedWorktreeCardActionState } from '../imported-worktrees-card-actions' +import type { NewExternalWorktreesInboxActionState } from '../new-external-worktrees-inbox-actions' +import { canKeepImportedWorktreesHidden } from './imported-worktrees-card-visibility' + +type NoticeRowFrameProps = { + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void + className?: string + children: React.ReactNode +} + +// Every non-card sidebar row shares the same absolutely-positioned virtual frame. +function NoticeRowFrame({ + vItem, + measureVirtualRowElement, + className = 'absolute left-0 right-0 top-0', + children +}: NoticeRowFrameProps): React.JSX.Element { + return ( +
+ {children} +
+ ) +} + +export function renderImportedWorktreesVirtualRow(args: { + row: Extract + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void + actionState: ImportedWorktreeCardActionState | undefined + onShow: (projectId: string) => void + onKeepHidden: (projectId: string) => void +}): React.JSX.Element { + const { row, actionState } = args + return ( + + args.onShow(row.repo.id)} + onKeepHidden={ + canKeepImportedWorktreesHidden(row, actionState) + ? () => args.onKeepHidden(row.repo.id) + : undefined + } + /> + + ) +} + +export function renderNewExternalWorktreesInboxVirtualRow(args: { + row: Extract + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void + actionState: NewExternalWorktreesInboxActionState | undefined + onReview: (repo: Extract['repo']) => void + onSuppress: (projectId: string) => void +}): React.JSX.Element { + const { row, actionState } = args + return ( + + args.onReview(row.repo)} + onSuppress={() => args.onSuppress(row.repo.id)} + /> + + ) +} + +export function renderPendingCreationVirtualRow(args: { + row: Extract + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void +}): React.JSX.Element { + return ( + + + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-option-dom.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-option-dom.ts new file mode 100644 index 00000000000..ec52da40a12 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-option-dom.ts @@ -0,0 +1,58 @@ +export function getWorktreeOptionId(rowKey: string): string { + return `worktree-list-option-${encodeURIComponent(rowKey)}` +} + +export function getMountedWorktreeOptions( + worktreeId: string, + root?: ParentNode | null +): HTMLElement[] { + const scope = root ?? document + const result: HTMLElement[] = [] + scope.querySelectorAll('[data-worktree-id]').forEach((element) => { + if (element.dataset.worktreeId === worktreeId) { + result.push(element) + } + }) + return result +} + +export function markSidebarWorktreeActiveImmediately( + worktreeId: string, + primaryRowKey?: string +): void { + const sidebar = document.querySelector('[data-worktree-sidebar]') + const nextOptions = getMountedWorktreeOptions(worktreeId, sidebar) + const nextOption = nextOptions[0] + if (!nextOption) { + return + } + + sidebar + ?.querySelectorAll('[role="option"][aria-current="page"]') + .forEach((option) => option.removeAttribute('aria-current')) + + for (const option of nextOptions) { + option.setAttribute('aria-current', 'page') + } + sidebar + ?.querySelectorAll('[data-worktree-card-surface][data-worktree-card-active]') + .forEach((surface) => { + if (!nextOptions.some((option) => option.contains(surface))) { + surface.removeAttribute('data-worktree-card-active') + } + }) + for (const option of nextOptions) { + const activeSurfaceVariant = + primaryRowKey !== undefined + ? option.dataset.worktreeRowKey === primaryRowKey + ? 'primary' + : 'secondary' + : option === nextOption + ? 'primary' + : 'secondary' + const surface = option.matches('[data-worktree-card-surface]') + ? option + : option.querySelector('[data-worktree-card-surface]') + surface?.setAttribute('data-worktree-card-active', activeSurfaceVariant) + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-commit.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-commit.ts new file mode 100644 index 00000000000..1e09a403885 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-commit.ts @@ -0,0 +1,159 @@ +import { + getWorkspaceKanbanSidebarDropGroups, + getWorkspaceKanbanSidebarDropTarget, + isWorkspaceKanbanSidebarDropPointInBoard, + resolveWorkspaceKanbanSidebarFullLaneDropIndex +} from '../workspace-kanban-sidebar-drop' +import { resolveWorkspaceKanbanCardDropCommitTarget } from '../workspace-kanban-card-pointer-drag-dom' +import { getFullDropIndexForWorktreeDragUnit } from '../worktree-drag-units' +import { resolveWorktreeSidebarStatusDropCommitTarget } from '../worktree-sidebar-drop-preview' +import { + getPointerDropStatusTarget, + shouldPreferSidebarStatusDropTarget +} from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' +import type { WorktreeSidebarStatusDropTarget } from '../worktree-sidebar-drop-preview' +import { + NO_WORKTREE_SIDEBAR_DROP_TARGET, + type WorktreePointerDrag +} from './worktree-row-drag-state' + +type PointerDropCommitArgs = { + event: PointerEvent + drag: WorktreePointerDrag + ctx: WorktreeDropCommitContext + onWorkspaceBoardDragPreviewCommit: () => void + onDropWorktreesOnWorkspaceBoard: (args: WorktreeStatusDropAtIndexArgs) => void +} + +function commitStatusOrPinDrop( + args: PointerDropCommitArgs, + target: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null }, + dropIndex: number | null +): void { + const { drag, ctx } = args + if (target.isPinDrop) { + ctx.onPinWorktrees(drag.draggedIds) + return + } + if (!target.status) { + return + } + if (dropIndex === null) { + ctx.onMoveWorktreesToStatus(drag.reorderDraggedIds, target.status) + return + } + ctx.onMoveWorktreesToStatusAtIndex({ + worktreeIds: drag.reorderDraggedIds, + status: target.status, + dropIndex, + groups: ctx.worktreeDragGroups + }) +} + +// Resolve where a released pointer drag lands: workspace board lane, lineage parent, +// status/pin section, or a reorder slot inside the source group. +export function commitWorktreePointerDrop(args: PointerDropCommitArgs): void { + const { event, drag, ctx } = args + if (!ctx.refreshWorktreeDragSession()) { + ctx.clearWorktreeDrag() + return + } + const boardDropTarget = resolveWorkspaceKanbanCardDropCommitTarget({ + currentTarget: getWorkspaceKanbanSidebarDropTarget(event.clientX, event.clientY), + latestTrackedTarget: drag.latestBoardDropTarget, + x: event.clientX, + y: event.clientY + }) + if (isWorkspaceKanbanSidebarDropPointInBoard(event.clientX, event.clientY)) { + args.onWorkspaceBoardDragPreviewCommit() + } + if (boardDropTarget.isPinDrop) { + ctx.onPinWorktrees(drag.draggedIds) + } else if (boardDropTarget.status) { + args.onDropWorktreesOnWorkspaceBoard({ + worktreeIds: drag.reorderDraggedIds, + status: boardDropTarget.status, + // Why: the target counts rendered cards, but the groups are the full + // lane. Board search can make those two differ. + dropIndex: resolveWorkspaceKanbanSidebarFullLaneDropIndex( + boardDropTarget.status, + boardDropTarget.dropIndex + ), + groups: getWorkspaceKanbanSidebarDropGroups() + }) + } else { + const preferredStatusTarget = ctx.getEligibleLineageDropTarget( + ctx.scrollRef.current + ? getPointerDropStatusTarget({ + container: ctx.scrollRef.current, + x: event.clientX, + y: event.clientY + }) + : NO_WORKTREE_SIDEBAR_DROP_TARGET, + drag.draggedIds + ) + if (preferredStatusTarget.lineageParentId) { + ctx.commitWorktreeLineageParentDrop(drag.draggedIds, preferredStatusTarget.lineageParentId) + ctx.clearWorktreeDrag() + return + } + if ( + shouldPreferSidebarStatusDropTarget({ + sourceGroupKey: drag.sourceGroupKey, + target: preferredStatusTarget, + workspaceStatuses: ctx.workspaceStatuses + }) + ) { + const statusDrop = preferredStatusTarget.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: preferredStatusTarget.status, + draggedIds: drag.reorderDraggedIds + }) + : null + commitStatusOrPinDrop(args, preferredStatusTarget, statusDrop?.dropIndex ?? null) + ctx.clearWorktreeDrag() + return + } + const drop = ctx.computeWorktreeDrop(event.clientY) + if (drop) { + ctx.onReorderWorktrees({ + groups: ctx.worktreeDragGroups, + sourceGroupKey: drag.sourceGroupKey, + draggedIds: drag.reorderDraggedIds, + dropIndex: getFullDropIndexForWorktreeDragUnit({ + groups: ctx.worktreeDragUnitGroups, + sourceGroupKey: drag.sourceGroupKey, + dropIndex: drop.dropIndex + }) + }) + ctx.clearReorderedWorktreeParents({ + draggedIds: drag.draggedIds, + sourceGroupKey: drag.sourceGroupKey + }) + } else if (ctx.scrollRef.current) { + const currentPreview = preferredStatusTarget.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: preferredStatusTarget.status, + draggedIds: drag.reorderDraggedIds + }) + : null + const { target, preview: statusDrop } = resolveWorktreeSidebarStatusDropCommitTarget({ + currentTarget: preferredStatusTarget, + currentPreview, + latestTrackedTarget: drag.latestStatusDropTarget, + x: event.clientX, + y: event.clientY + }) + if (target.lineageParentId) { + ctx.commitWorktreeLineageParentDrop(drag.draggedIds, target.lineageParentId) + } else { + commitStatusOrPinDrop(args, target, statusDrop?.dropIndex ?? null) + } + } + } + ctx.clearWorktreeDrag() +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-flush.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-flush.ts new file mode 100644 index 00000000000..90d70d4534b --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-flush.ts @@ -0,0 +1,172 @@ +import type React from 'react' +import type { WorkspaceStatus } from '../../../../../shared/worktree/types' +import { + clearWorkspaceKanbanSidebarDropTargetVisual, + hasWorkspaceKanbanSidebarDropBoard, + isWorkspaceKanbanSidebarDropPointInBoard, + updateWorkspaceKanbanSidebarDropTargetVisual +} from '../workspace-kanban-sidebar-drop' +import { updateSidebarDragPreviewPosition } from '../worktree-sidebar-pointer-drag-dom' +import { + getPointerDropStatusTarget, + shouldPreferSidebarStatusDropTarget +} from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import { + applyWorktreeDropPreview, + clearWorktreeDropPreview, + NO_WORKTREE_SIDEBAR_DROP_TARGET, + updateLatestWorktreeStatusDropTarget, + type WorktreePointerDrag, + type WorktreeRowDragState, + type WorktreeSidebarLineageDropTarget +} from './worktree-row-drag-state' + +export type WorktreePointerDragFrameArgs = { + drag: WorktreePointerDrag + ctx: WorktreeDropCommitContext + workspaceBoardOpen: boolean + onWorkspaceBoardDragPreviewStart: () => void + onWorkspaceBoardDragPreviewCommit: () => void + shouldShowWorkspaceBoardDropIndicator: ( + worktreeIds: readonly string[], + status: WorkspaceStatus + ) => boolean + setWorktreeDragState: React.Dispatch> + setDragOverStatus: (status: WorkspaceStatus | null) => void + setPinDragOver: (pinDragOver: boolean) => void +} + +// Reflect a status/pin hover that has no insertion line of its own. +function showStatusHoverWithoutInsertionLine( + args: WorktreePointerDragFrameArgs, + target: WorktreeSidebarLineageDropTarget +): void { + const { drag, ctx } = args + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: drag.currentY, + status: target.status, + draggedIds: drag.reorderDraggedIds + }) + : null + updateLatestWorktreeStatusDropTarget(drag, target, statusDrop) + if (statusDrop) { + clearWorkspaceKanbanSidebarDropTargetVisual() + args.setDragOverStatus(null) + args.setPinDragOver(false) + args.setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, statusDrop, { + pointerY: drag.currentY, + matchPointerY: true + }) + ) + return + } + args.setDragOverStatus(target.status) + args.setPinDragOver(target.isPinDrop) + args.setWorktreeDragState((prev) => + clearWorktreeDropPreview(prev, { pointerY: drag.currentY, matchPointerY: true }) + ) +} + +function clearInsertionLine(args: WorktreePointerDragFrameArgs): void { + args.setDragOverStatus(null) + args.setPinDragOver(false) + args.setWorktreeDragState((prev) => + clearWorktreeDropPreview(prev, { pointerY: args.drag.currentY, matchPointerY: true }) + ) +} + +// One animation frame of an in-flight pointer drag: move the floating preview, then decide +// whether the pointer is over the workspace board, a status/pin section, or a reorder slot. +export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs): void { + const { drag, ctx } = args + drag.frameId = null + if (!drag.active || !drag.preview) { + return + } + updateSidebarDragPreviewPosition({ + preview: drag.preview, + pointerX: drag.currentX, + pointerY: drag.currentY, + offsetX: drag.previewOffsetX, + offsetY: drag.previewOffsetY + }) + if (!ctx.refreshWorktreeDragSession()) { + ctx.clearWorktreeDrag() + return + } + // Why: show the board preview as soon as a card drag begins so the drop target is visible up front, not only at the sidebar edge. + if ( + !drag.workspaceBoardDragPreviewRequested && + !args.workspaceBoardOpen && + !hasWorkspaceKanbanSidebarDropBoard() + ) { + drag.workspaceBoardDragPreviewRequested = true + args.onWorkspaceBoardDragPreviewStart() + } + const boardTarget = updateWorkspaceKanbanSidebarDropTargetVisual({ + x: drag.currentX, + y: drag.currentY, + shouldShowDropIndicator: (target) => + Boolean( + target.status && + args.shouldShowWorkspaceBoardDropIndicator(drag.reorderDraggedIds, target.status) + ) + }) + drag.latestBoardDropTarget = { + target: boardTarget, + x: drag.currentX, + y: drag.currentY + } + if (isWorkspaceKanbanSidebarDropPointInBoard(drag.currentX, drag.currentY)) { + args.onWorkspaceBoardDragPreviewCommit() + } + if (boardTarget.status || boardTarget.isPinDrop) { + drag.latestStatusDropTarget = null + clearInsertionLine(args) + return + } + + const sidebarContainer = ctx.scrollRef.current + const preferredStatusTarget = ctx.getEligibleLineageDropTarget( + sidebarContainer + ? getPointerDropStatusTarget({ + container: sidebarContainer, + x: drag.currentX, + y: drag.currentY + }) + : NO_WORKTREE_SIDEBAR_DROP_TARGET, + drag.draggedIds + ) + if (preferredStatusTarget.lineageParentId) { + updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, null) + clearWorkspaceKanbanSidebarDropTargetVisual() + clearInsertionLine(args) + return + } + if ( + shouldPreferSidebarStatusDropTarget({ + sourceGroupKey: drag.sourceGroupKey, + target: preferredStatusTarget, + workspaceStatuses: ctx.workspaceStatuses + }) + ) { + showStatusHoverWithoutInsertionLine(args, preferredStatusTarget) + return + } + + const drop = ctx.computeWorktreeDrop(drag.currentY) + if (!drop) { + showStatusHoverWithoutInsertionLine(args, preferredStatusTarget) + return + } + drag.latestStatusDropTarget = null + clearWorkspaceKanbanSidebarDropTargetVisual() + args.setDragOverStatus(null) + args.setPinDragOver(false) + args.setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, drop, { pointerY: drag.currentY, matchPointerY: true }) + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-row-drag-state.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-row-drag-state.ts new file mode 100644 index 00000000000..2bb47089e22 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-row-drag-state.ts @@ -0,0 +1,132 @@ +import type { WorkspaceKanbanCardTrackedDropTarget } from '../workspace-kanban-card-pointer-drag-dom' +import type { WorktreeSidebarDragRect } from '../worktree-sidebar-drag-autoscroll' +import type { + WorktreeSidebarDropPreview, + WorktreeSidebarStatusDropTarget, + WorktreeSidebarTrackedStatusDropTarget +} from '../worktree-sidebar-drop-preview' + +export type WorktreeRowDragState = { + draggingWorktreeId: string | null + sourceGroupKey: string | null + dropIndex: number | null + dropIndicatorY: number | null + previewOffsetsByWorktreeId: ReadonlyMap + pointerY: number | null +} + +export const EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS: ReadonlyMap = new Map() + +export const WORKTREE_ROW_DRAG_INITIAL_STATE: WorktreeRowDragState = { + draggingWorktreeId: null, + sourceGroupKey: null, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: null +} + +export type WorktreePointerDrag = { + pointerId: number + sourceRow: HTMLElement + startX: number + startY: number + currentX: number + currentY: number + worktreeId: string + draggedIds: readonly string[] + reorderDraggedIds: readonly string[] + reorderUnitDraggedIds: readonly string[] + sourceGroupKey: string + rects: readonly WorktreeSidebarDragRect[] + active: boolean + preview: HTMLElement | null + previewOffsetX: number + previewOffsetY: number + workspaceBoardDragPreviewRequested: boolean + frameId: number | null + latestBoardDropTarget: WorkspaceKanbanCardTrackedDropTarget | null + latestStatusDropTarget: WorktreeSidebarTrackedStatusDropTarget | null +} + +export type WorktreeSidebarLineageDropTarget = WorktreeSidebarStatusDropTarget & { + lineageParentId: string | null +} + +export const NO_WORKTREE_SIDEBAR_DROP_TARGET: WorktreeSidebarLineageDropTarget = { + status: null, + isPinDrop: false, + lineageParentId: null +} + +export function areWorktreeDragPreviewOffsetsEqual( + a: ReadonlyMap, + b: ReadonlyMap +): boolean { + if (a === b) { + return true + } + if (a.size !== b.size) { + return false + } + for (const [key, value] of a) { + if (b.get(key) !== value) { + return false + } + } + return true +} + +export function updateLatestWorktreeStatusDropTarget( + drag: WorktreePointerDrag, + target: WorktreeSidebarLineageDropTarget, + preview: WorktreeSidebarDropPreview | null +): void { + drag.latestStatusDropTarget = + target.status || target.isPinDrop || target.lineageParentId + ? { + target, + preview, + x: drag.currentX, + y: drag.currentY + } + : null +} + +// Why matchPointerY: pointer-driven updates must repaint when only the pointer moved; native-drag +// updates deliberately keep the previous state identity in that case. +export function clearWorktreeDropPreview( + previous: WorktreeRowDragState, + args: { pointerY: number | null; matchPointerY?: boolean } +): WorktreeRowDragState { + const unchanged = + previous.dropIndex === null && + previous.dropIndicatorY === null && + previous.previewOffsetsByWorktreeId.size === 0 && + (args.matchPointerY !== true || previous.pointerY === args.pointerY) + return unchanged + ? previous + : { + ...previous, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: args.pointerY + } +} + +export function applyWorktreeDropPreview( + previous: WorktreeRowDragState, + drop: WorktreeSidebarDropPreview, + args: { pointerY: number; matchPointerY?: boolean } +): WorktreeRowDragState { + const unchanged = + previous.dropIndex === drop.dropIndex && + previous.dropIndicatorY === drop.dropIndicatorY && + (args.matchPointerY !== true || previous.pointerY === args.pointerY) && + areWorktreeDragPreviewOffsetsEqual( + previous.previewOffsetsByWorktreeId, + drop.previewOffsetsByWorktreeId + ) + return unchanged ? previous : { ...previous, ...drop, pointerY: args.pointerY } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-section-header-row.tsx b/src/renderer/src/components/sidebar/worktree-list/worktree-section-header-row.tsx new file mode 100644 index 00000000000..dd77e3dd0da --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-section-header-row.tsx @@ -0,0 +1,393 @@ +import React from 'react' +import { ChevronDown } from 'lucide-react' +import type { VirtualItem } from '@tanstack/react-virtual' +import { cn } from '@/lib/utils' +import type { AppState } from '@/store/types' +import { RepoIconGlyph } from '@/components/repo/repo-icon' +import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' +import type { FolderWorkspacePathStatus } from '../../../../../shared/folder-workspace-path-status' +import { isConfirmedStaleFolderPathStatus } from '../../../../../shared/folder-workspace-path-status' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition +} from '../../../../../shared/worktree/types' +import type { GroupHeaderRow, WorktreeGroupBy } from '../worktree-list-groups' +import { PINNED_GROUP_KEY } from '../worktree-list-groups' +import { getWorkspaceStatusFromGroupKey } from '../workspace-status' +import { getVirtualRowTransform } from '../worktree-list-virtual-rows' +import { resolveProjectGroupHeaderColor } from '../project-header-color' +import { getRepoHeaderCreateState } from '../repo-header-create-state' +import { ProjectHeaderActions } from '../ProjectHeaderActions' +import { + getProjectGroupHeaderPaddingLeft, + WORKTREE_SECTION_HEADER_PADDING_LEFT +} from '../worktree-list-indentation' +import { FolderPathStatusIndicator } from './FolderPathStatusIndicator' +import { + ProjectGroupCreateWorkspaceButton, + ProjectGroupHeaderMenu +} from './project-group-header-actions' +import { + RepoHeaderCreateWorkspaceButton, + RepoHeaderProjectActionsMenu, + type RepoHeaderProjectActions +} from './repo-header-project-actions' +import { + handleRepoHeaderCollapseAffordancePointerDown, + shouldIgnoreRepoHeaderToggle +} from './repo-header-event-guards' +import type { WorktreeSidebarHeaderDrag } from './use-worktree-sidebar-header-drag' +import { getWorktreeOptionId } from './worktree-option-dom' + +export type SectionHeaderRowContext = { + groupBy: WorktreeGroupBy + collapsedGroups: Set + workspaceStatuses: readonly WorkspaceStatusDefinition[] + projectGroups: readonly ProjectGroup[] + sshConnectionStates: AppState['sshConnectionStates'] + highlightedRevealRowKey: string | null + dragOverStatus: WorkspaceStatus | null + pinDragOver: boolean + headerDrag: WorktreeSidebarHeaderDrag + getCachedFolderWorkspacePathStatus: (request: { + scope: 'project-group' + projectGroupId: string + }) => FolderWorkspacePathStatus | null + toggleGroupWithScrollAnchor: (groupKey: string) => void + projectActions: RepoHeaderProjectActions + onRenameProjectGroup: (groupId: string, currentName: string) => void + onDeleteProjectGroup: (groupId: string, groupName: string) => void + onCreateFolderWorkspace: (projectGroup: ProjectGroup) => void + onWorkspaceStatusDragOver: (event: React.DragEvent, status: WorkspaceStatus) => void + onWorkspaceStatusDragLeave: (event: React.DragEvent) => void + onWorkspacePinDragOver: (event: React.DragEvent) => void + onWorkspacePinDragLeave: (event: React.DragEvent) => void + onWorkspaceStatusDrop: (event: React.DragEvent, status: WorkspaceStatus) => void +} + +// The folder-scan project group whose parent path is gone can't create new workspaces. +function isFolderWorkspaceCreateDisabled(status: FolderWorkspacePathStatus | null): boolean { + return ( + status?.exists === false && + (isConfirmedStaleFolderPathStatus(status) || status.reason === 'ambiguous-connection') + ) +} + +export function renderWorktreeSectionHeaderRow(args: { + ctx: SectionHeaderRowContext + row: GroupHeaderRow + vItem: VirtualItem + isActiveStickyHeader: boolean + hasStickyHost: boolean + hasHeaderTopSpacing: boolean + measureVirtualRowElement: (element: HTMLDivElement | null) => void +}): React.JSX.Element { + const { ctx, row, vItem, isActiveStickyHeader } = args + const { headerDrag } = ctx + const isRepoHeader = ctx.groupBy === 'repo' && row.repo !== undefined + const isProjectGroupHeader = ctx.groupBy === 'repo' && row.projectGroup !== undefined + const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined + const projectGroupIdForHeader = + isProjectGroupHeader && !row.repo && typeof row.projectGroup?.id === 'string' + ? row.projectGroup.id + : undefined + const repoHeaderIndex = + projectIdForHeader !== undefined + ? headerDrag.repoHeaderIndexByRepoId.get(projectIdForHeader) + : undefined + const repoHeaderBucketKey = + projectIdForHeader !== undefined + ? headerDrag.repoHeaderBucketByRepoId.get(projectIdForHeader) + : undefined + const projectGroupHeaderIndex = + projectGroupIdForHeader !== undefined + ? headerDrag.projectGroupHeaderIndexByGroupId.get(projectGroupIdForHeader) + : undefined + const projectGroupHeaderBucketKey = + projectGroupIdForHeader !== undefined + ? headerDrag.projectGroupHeaderBucketByGroupId.get(projectGroupIdForHeader) + : undefined + const isDraggableRepoHeader = Boolean( + headerDrag.canReorderRepoHeaders && + isRepoHeader && + projectIdForHeader && + repoHeaderBucketKey && + (headerDrag.sidebarRepoHeaderIdsByBucket.get(repoHeaderBucketKey)?.length ?? 0) > 1 + ) + const isDraggableProjectGroupHeader = Boolean( + headerDrag.canReorderProjectGroupHeaders && + projectGroupIdForHeader && + projectGroupHeaderBucketKey && + (headerDrag.sidebarProjectGroupHeaderIdsByBucket.get(projectGroupHeaderBucketKey)?.length ?? + 0) > 1 + ) + const isDraggingThis = + headerDrag.canReorderRepoHeaders && + headerDrag.repoDrag.state.draggingRepoId !== null && + headerDrag.repoDrag.state.draggingRepoId === projectIdForHeader + const isDraggingThisProjectGroup = + headerDrag.canReorderProjectGroupHeaders && + headerDrag.projectGroupDrag.state.draggingGroupId !== null && + headerDrag.projectGroupDrag.state.draggingGroupId === projectGroupIdForHeader + const headerWorkspaceStatus = + ctx.groupBy === 'workspace-status' + ? getWorkspaceStatusFromGroupKey(row.key, ctx.workspaceStatuses) + : null + const isPinnedHeader = row.key === PINNED_GROUP_KEY + const repoHeaderColor = resolveProjectGroupHeaderColor({ + groupBy: ctx.groupBy, + headerKey: row.key, + badgeColor: row.repo?.badgeColor + }) + const createState = row.repo + ? getRepoHeaderCreateState({ + repo: row.repo, + label: row.label, + sshStatus: row.repo.connectionId + ? (ctx.sshConnectionStates.get(row.repo.connectionId)?.status ?? null) + : null + }) + : null + const folderBackedProjectGroup = + isProjectGroupHeader && + !row.repo && + row.projectGroup && + 'parentPath' in row.projectGroup && + row.projectGroup.parentPath + ? row.projectGroup + : null + const projectGroupPathStatus = folderBackedProjectGroup + ? ctx.getCachedFolderWorkspacePathStatus({ + scope: 'project-group', + projectGroupId: folderBackedProjectGroup.id + }) + : null + const isHeaderCollapsed = ctx.collapsedGroups.has(row.key) + // Why: repo/project/status/pinned share compact section chrome; flat "All" stays a simple label. + const showHeaderCollapseAffordance = + row.count > 0 && + (isRepoHeader || isProjectGroupHeader || headerWorkspaceStatus !== null || isPinnedHeader) + return ( +
+
ctx.onWorkspaceStatusDragOver(event, headerWorkspaceStatus) + : undefined + } + onDragLeave={ + isPinnedHeader + ? ctx.onWorkspacePinDragLeave + : headerWorkspaceStatus + ? ctx.onWorkspaceStatusDragLeave + : undefined + } + onDrop={ + headerWorkspaceStatus + ? (event) => ctx.onWorkspaceStatusDrop(event, headerWorkspaceStatus) + : undefined + } + onPointerDown={ + isDraggableRepoHeader && projectIdForHeader + ? (event) => headerDrag.repoDrag.onHandlePointerDown(event, projectIdForHeader) + : isDraggableProjectGroupHeader && projectGroupIdForHeader + ? (event) => + headerDrag.projectGroupDrag.onHandlePointerDown(event, projectGroupIdForHeader) + : undefined + } + onClick={(event) => { + if (shouldIgnoreRepoHeaderToggle(event)) { + return + } + ctx.toggleGroupWithScrollAnchor(row.key) + }} + onKeyDown={(e) => { + if (shouldIgnoreRepoHeaderToggle(e)) { + return + } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + ctx.toggleGroupWithScrollAnchor(row.key) + } + }} + > + {/* Why: grab cursor on icon+title only. Row still has handle attrs so + indent/padding can arm drag; actions are excluded via data-repo-header-actions. + self-stretch fills h-7 so grab matches the full title column height. */} +
+ {row.icon ? ( +
+ {row.repo ? ( + + ) : ( + + )} +
+ ) : null} + +
+
+
+ {row.label} +
+ + +
+
+
+ + + {showHeaderCollapseAffordance ? ( +
{ + event.preventDefault() + event.stopPropagation() + ctx.toggleGroupWithScrollAnchor(row.key) + }} + > + +
+ ) : null} + + {isProjectGroupHeader && !row.repo && projectGroupIdForHeader ? ( + + ) : null} + + {folderBackedProjectGroup ? ( + + ) : null} + + {row.repo && ctx.groupBy === 'repo' ? ( + + ) : null} + + {row.repo && ctx.groupBy === 'repo' ? ( + + ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-sidebar-scroll-tuning.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-sidebar-scroll-tuning.ts new file mode 100644 index 00000000000..bb404507e26 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-sidebar-scroll-tuning.ts @@ -0,0 +1,42 @@ +import type React from 'react' + +// Debounce re-sort after a sortEpoch bump so background score changes don't jar row positions. +export const SORT_SETTLE_MS = 3_000 +export const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500 +export const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300 +export const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4 + +export const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { + // Why: TanStack Virtual owns scroll correction; native overflow anchoring fights it and causes jumps. + overflowAnchor: 'none' +} + +const recordKeyCountCache = new WeakMap, number>() + +export function countRecordKeysByReference(record: Record): number { + const cached = recordKeyCountCache.get(record) + if (cached !== undefined) { + return cached + } + const count = Object.keys(record).length + recordKeyCountCache.set(record, count) + return count +} + +export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: { + isScrolling: boolean + now: number + suppressUntil: number +}): boolean { + return !args.isScrolling && args.now >= args.suppressUntil +} + +export function resolvePendingSidebarReveal(args: { + targetIndex: number + targetWorktreeStillExists: boolean +}): 'scroll-and-clear' | 'clear' | 'keep-pending' { + if (args.targetIndex !== -1) { + return 'scroll-and-clear' + } + return args.targetWorktreeStillExists ? 'keep-pending' : 'clear' +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-context.ts new file mode 100644 index 00000000000..2e0d2defa45 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-context.ts @@ -0,0 +1,157 @@ +import type React from 'react' +import type { AppState } from '@/store/types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { RenderRow } from '../worktree-list-virtual-rows' +import type { LineageToggleHandler } from '../worktree-lineage-toggle-handler-cache' +import type { SidebarRevealHighlight } from './use-sidebar-reveal-highlight' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import type { useFolderWorkspacePathStatusRows } from './use-folder-workspace-path-status-rows' +import type { usePrimaryActiveWorktreeRow } from './use-primary-active-worktree-row' +import type { useWorkspaceStatusRowDrag } from './use-workspace-status-row-drag' +import type { useWorktreeNativeDrag } from './use-worktree-native-drag' +import type { WorktreeSidebarHeaderDrag } from './use-worktree-sidebar-header-drag' +import type { WorktreeListVirtualizer } from './use-worktree-list-virtualizer' +import type { VirtualizedWorktreeViewportProps } from './virtualized-worktree-viewport-props' +import type { WorktreeVirtualRowContext } from './worktree-virtual-row-dispatch' +import { getRepoOwnerWorktreeVisibilityDefaults } from '../../../store/worktree-visibility-defaults-by-host' + +type BuildArgs = { + props: VirtualizedWorktreeViewportProps + projectGroups: readonly ProjectGroup[] + renderRows: RenderRow[] + firstHeaderIndex: number + virtualization: WorktreeListVirtualizer + measureVirtualRowElement: (element: HTMLDivElement | null) => void + settings: AppState['settings'] + worktreeVisibilityDefaultsByHost: AppState['worktreeVisibilityDefaultsByHost'] + sshConnectionStates: AppState['sshConnectionStates'] + newCardStyle: boolean + folderBackedProjectGroupIds: ReadonlySet + session: WorktreeDragSession + runtime: WorktreeDragRuntime + primaryActive: ReturnType + reveal: SidebarRevealHighlight + statusDrag: ReturnType + nativeDrag: ReturnType + headerDrag: WorktreeSidebarHeaderDrag + getCachedFolderWorkspacePathStatus: ReturnType + getLineageToggleHandler: (groupKey: string) => LineageToggleHandler + toggleGroupWithScrollAnchor: (groupKey: string) => void + onRowClickCapture: (event: React.MouseEvent) => void + onRowPointerDown: ( + event: React.PointerEvent, + worktreeId: string, + rowKey: string + ) => void +} + +// Assembles the per-render context the row dispatcher reads. Plain data — everything in it +// is either a prop, a memoised hook result, or a stable callback. +export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtualRowContext { + const { props, runtime, session, statusDrag, headerDrag, primaryActive, reveal } = args + return { + renderRows: args.renderRows, + firstHeaderIndex: args.firstHeaderIndex, + activeStickyHeaderIndexRef: args.virtualization.activeStickyHeaderIndexRef, + activeStickyHostIndexRef: args.virtualization.activeStickyHostIndexRef, + measureVirtualRowElement: args.measureVirtualRowElement, + groupBy: props.groupBy, + workspaceStatuses: props.workspaceStatuses, + activeWorktreeId: props.activeWorktreeId, + worktreeDragState: runtime.worktreeDragState, + orderedHostIds: headerDrag.orderedHostIds, + hostDrag: headerDrag.hostDrag, + toggleGroupWithScrollAnchor: args.toggleGroupWithScrollAnchor, + importedWorktreeCardActionState: props.importedWorktreeCardActionState, + newExternalWorktreeInboxActionState: props.newExternalWorktreeInboxActionState, + onShowImportedWorktrees: props.handleShowImportedWorktrees, + onKeepImportedWorktreesHidden: props.handleKeepImportedWorktreesHidden, + onOpenWorktreeVisibility: props.handleOpenWorktreeVisibility, + onOpenSuppressExternalWorktreeInbox: props.handleOpenSuppressExternalWorktreeInbox, + onWorkspaceStatusDragOver: statusDrag.handleWorkspaceStatusDragOver, + onWorkspaceStatusDragLeave: statusDrag.handleWorkspaceStatusDragLeave, + onWorkspaceStatusDrop: statusDrag.handleWorkspaceStatusDrop, + header: { + groupBy: props.groupBy, + collapsedGroups: props.collapsedGroups, + workspaceStatuses: props.workspaceStatuses, + projectGroups: args.projectGroups, + sshConnectionStates: args.sshConnectionStates, + highlightedRevealRowKey: reveal.highlightedRevealRowKey, + dragOverStatus: runtime.dragOverStatus, + pinDragOver: runtime.pinDragOver, + headerDrag, + getCachedFolderWorkspacePathStatus: args.getCachedFolderWorkspacePathStatus, + toggleGroupWithScrollAnchor: args.toggleGroupWithScrollAnchor, + projectActions: { + getWorktreeVisibilityDefaults: (repo) => + getRepoOwnerWorktreeVisibilityDefaults( + repo, + args.settings, + args.worktreeVisibilityDefaultsByHost + ), + onOpenRepoSettings: props.handleOpenRepoSettings, + onOpenWorktreeVisibility: props.handleOpenWorktreeVisibility, + onCreateGroupFromRepo: props.handleCreateGroupFromRepo, + onMoveProjectToGroup: props.handleMoveProjectToGroup, + onRemoveProjectFromGroup: props.handleRemoveProjectFromGroup, + onRemoveProject: props.handleRemoveProject, + onCreateForRepo: props.handleCreateForRepo + }, + onRenameProjectGroup: props.handleRenameProjectGroup, + onDeleteProjectGroup: props.handleDeleteProjectGroup, + onCreateFolderWorkspace: props.handleCreateFolderWorkspace, + onWorkspaceStatusDragOver: statusDrag.handleWorkspaceStatusDragOver, + onWorkspaceStatusDragLeave: statusDrag.handleWorkspaceStatusDragLeave, + onWorkspacePinDragOver: statusDrag.handleWorkspacePinDragOver, + onWorkspacePinDragLeave: statusDrag.handleWorkspacePinDragLeave, + onWorkspaceStatusDrop: statusDrag.handleWorkspaceStatusDrop + }, + item: { + settings: args.settings, + groupBy: props.groupBy, + folderBackedProjectGroupIds: args.folderBackedProjectGroupIds, + groupKeyByRowKey: session.groupKeyByRowKey, + groupIndexByRowKey: session.groupIndexByRowKey, + agentSendTargetWorktreeId: props.agentSendTargetWorktreeId, + worktreeDragState: runtime.worktreeDragState, + worktreePointerDragRef: runtime.worktreePointerDragRef, + nativeLineageDropTargetId: runtime.nativeLineageDropTargetId, + activeWorktreeId: props.activeWorktreeId, + currentWorktreeId: props.currentWorktreeId, + highlightedRevealRowKey: reveal.highlightedRevealRowKey, + selectedWorktreeIds: props.selectedWorktreeIds, + selectedWorktrees: props.selectedWorktrees, + getActiveSurfaceVariant: primaryActive.getActiveSurfaceVariant, + getLineageToggleHandler: args.getLineageToggleHandler, + onSelectionGesture: props.onSelectionGesture, + onContextMenuSelect: props.onContextMenuSelect, + onImmediateActivate: primaryActive.handleImmediateWorktreeRowActivate, + onRowClickCapture: args.onRowClickCapture, + onRowPointerDown: args.onRowPointerDown, + onCardDragStart: args.nativeDrag.handleWorktreeCardDragStart, + onCardDragEnd: runtime.clearWorktreeDrag + }, + folderWorkspace: { + groupBy: props.groupBy, + newCardStyle: args.newCardStyle, + settings: args.settings, + activeWorktreeId: props.activeWorktreeId, + currentWorktreeId: props.currentWorktreeId, + selectedWorktreeIds: props.selectedWorktreeIds, + repoMap: props.repoMap, + worktreeMap: props.worktreeMap, + worktreeLineageById: props.worktreeLineageById, + workspaceLineageByChildKey: props.workspaceLineageByChildKey, + prCache: props.prCache, + hostedReviewCache: props.hostedReviewCache, + getCachedFolderWorkspacePathStatus: args.getCachedFolderWorkspacePathStatus, + onSelectionGesture: props.onSelectionGesture, + onContextMenuSelect: props.onContextMenuSelect, + onImmediateActivate: primaryActive.handleImmediateWorktreeRowActivate, + onRowClickCapture: args.onRowClickCapture, + onRowPointerDown: args.onRowPointerDown + } + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-dispatch.tsx b/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-dispatch.tsx new file mode 100644 index 00000000000..b17c81439d9 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-dispatch.tsx @@ -0,0 +1,262 @@ +import React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import { cn } from '@/lib/utils' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition +} from '../../../../../shared/worktree/types' +import { getWorkspaceStatus } from '../workspace-status' +import type { WorktreeGroupBy } from '../worktree-list-groups' +import { + getVirtualRowTransform, + shouldUseHeaderTopSpacing, + type RenderRow +} from '../worktree-list-virtual-rows' +import type { ImportedWorktreeCardActionState } from '../imported-worktrees-card-actions' +import type { NewExternalWorktreesInboxActionState } from '../new-external-worktrees-inbox-actions' +import type { useHostHeaderDrag } from '../host-header-drag' +import { HostSectionHeader } from './HostSectionHeader' +import { + renderFolderWorkspaceVirtualRow, + type FolderWorkspaceRowContext +} from './folder-workspace-virtual-row' +import { + renderImportedWorktreesVirtualRow, + renderNewExternalWorktreesInboxVirtualRow, + renderPendingCreationVirtualRow +} from './worktree-notice-virtual-rows' +import { + renderWorktreeItemRow, + renderWorktreeLineageDescendants, + type WorktreeItemRowContext +} from './worktree-list-item-row' +import { + renderWorktreeSectionHeaderRow, + type SectionHeaderRowContext +} from './worktree-section-header-row' +import { getWorktreeVirtualRowTransform } from './virtual-row-dom-attributes' +import type { WorktreeRowDragState } from './worktree-row-drag-state' + +export type WorktreeVirtualRowContext = { + renderRows: RenderRow[] + firstHeaderIndex: number + activeStickyHeaderIndexRef: React.MutableRefObject + activeStickyHostIndexRef: React.MutableRefObject + measureVirtualRowElement: (element: HTMLDivElement | null) => void + groupBy: WorktreeGroupBy + workspaceStatuses: readonly WorkspaceStatusDefinition[] + activeWorktreeId: string | null + worktreeDragState: WorktreeRowDragState + orderedHostIds: readonly string[] + hostDrag: ReturnType + toggleGroupWithScrollAnchor: (groupKey: string) => void + header: SectionHeaderRowContext + item: WorktreeItemRowContext + folderWorkspace: FolderWorkspaceRowContext + importedWorktreeCardActionState: ReadonlyMap + newExternalWorktreeInboxActionState: ReadonlyMap + onShowImportedWorktrees: (projectId: string) => void + onKeepImportedWorktreesHidden: (projectId: string) => void + onOpenWorktreeVisibility: ( + repo: Parameters[0] + ) => void + onOpenSuppressExternalWorktreeInbox: (projectId: string) => void + onWorkspaceStatusDragOver: (event: React.DragEvent, status: WorkspaceStatus) => void + onWorkspaceStatusDragLeave: (event: React.DragEvent) => void + onWorkspaceStatusDrop: (event: React.DragEvent, status: WorkspaceStatus) => void +} + +function renderHostHeaderVirtualRow( + ctx: WorktreeVirtualRowContext, + row: Extract, + vItem: VirtualItem +): React.JSX.Element { + // Why: the host card is the outer tier; it pins above group headers (z-30 vs z-20) and stays put as they hand off. + const isActiveStickyHost = ctx.activeStickyHostIndexRef.current === vItem.index + const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ + rows: ctx.renderRows, + index: vItem.index, + firstHeaderIndex: ctx.firstHeaderIndex + }) + return ( +
+ ctx.toggleGroupWithScrollAnchor(row.key)} + onDragPointerDown={ + ctx.orderedHostIds.length > 1 + ? (e) => ctx.hostDrag.onHandlePointerDown(e, row.hostId) + : undefined + } + dragging={ctx.hostDrag.state.draggingHostId === row.hostId} + /> +
+ ) +} + +function renderLineageGroupVirtualRow( + ctx: WorktreeVirtualRowContext, + row: Extract, + vItem: VirtualItem +): React.JSX.Element { + const [parent, ...children] = row.rows + const childIsActive = children.some((child) => child.worktree.id === ctx.activeWorktreeId) + const parentPreviewOffset = parent + ? (ctx.worktreeDragState.previewOffsetsByWorktreeId.get(parent.worktree.id) ?? 0) + : 0 + return ( +
+
+ {parent + ? renderWorktreeItemRow( + ctx.item, + parent, + false, + renderWorktreeLineageDescendants(ctx.item, parent, children), + childIsActive + ) + : null} +
+
+ ) +} + +// One virtual slot -> one row renderer. Every branch owns its own absolute/sticky frame +// because sticky headers and lineage groups position differently from plain cards. +export function renderWorktreeVirtualRow( + ctx: WorktreeVirtualRowContext, + row: RenderRow, + vItem: VirtualItem +): React.JSX.Element | null { + if (row.type === 'host-header') { + return renderHostHeaderVirtualRow(ctx, row, vItem) + } + + if (row.type === 'header') { + return renderWorktreeSectionHeaderRow({ + ctx: ctx.header, + row, + vItem, + isActiveStickyHeader: ctx.activeStickyHeaderIndexRef.current === vItem.index, + hasStickyHost: ctx.activeStickyHostIndexRef.current !== null, + hasHeaderTopSpacing: shouldUseHeaderTopSpacing({ + rows: ctx.renderRows, + index: vItem.index, + firstHeaderIndex: ctx.firstHeaderIndex + }), + measureVirtualRowElement: ctx.measureVirtualRowElement + }) + } + + if (row.type === 'lineage-group') { + return renderLineageGroupVirtualRow(ctx, row, vItem) + } + + if (row.type === 'imported-worktrees-card') { + return renderImportedWorktreesVirtualRow({ + row, + vItem, + measureVirtualRowElement: ctx.measureVirtualRowElement, + actionState: ctx.importedWorktreeCardActionState.get(row.repo.id), + onShow: ctx.onShowImportedWorktrees, + onKeepHidden: ctx.onKeepImportedWorktreesHidden + }) + } + + if (row.type === 'new-external-worktrees-inbox') { + return renderNewExternalWorktreesInboxVirtualRow({ + row, + vItem, + measureVirtualRowElement: ctx.measureVirtualRowElement, + actionState: ctx.newExternalWorktreeInboxActionState.get(row.repo.id), + onReview: ctx.onOpenWorktreeVisibility, + onSuppress: ctx.onOpenSuppressExternalWorktreeInbox + }) + } + + if (row.type === 'pending-creation') { + return renderPendingCreationVirtualRow({ + row, + vItem, + measureVirtualRowElement: ctx.measureVirtualRowElement + }) + } + + if (row.type === 'folder-workspace') { + return renderFolderWorkspaceVirtualRow({ + ctx: ctx.folderWorkspace, + row, + vItem, + measureVirtualRowElement: ctx.measureVirtualRowElement + }) + } + + const itemWorkspaceStatus = + ctx.groupBy === 'workspace-status' + ? getWorkspaceStatus(row.worktree, ctx.workspaceStatuses) + : null + const itemPreviewOffset = + ctx.worktreeDragState.previewOffsetsByWorktreeId.get(row.worktree.id) ?? 0 + + return ( +
ctx.onWorkspaceStatusDragOver(event, itemWorkspaceStatus) + : undefined + } + onDragLeave={itemWorkspaceStatus ? ctx.onWorkspaceStatusDragLeave : undefined} + onDrop={ + itemWorkspaceStatus + ? (event) => ctx.onWorkspaceStatusDrop(event, itemWorkspaceStatus) + : undefined + } + > + {renderWorktreeItemRow(ctx.item, row, false)} +
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-visibility-menu-label.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-visibility-menu-label.ts new file mode 100644 index 00000000000..93d3acdabc2 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-visibility-menu-label.ts @@ -0,0 +1,18 @@ +import type { Repo } from '../../../../../shared/repo-types' +import type { WorktreeVisibilityDefaults } from '../../../../../shared/global-settings-types' +import { + effectiveExternalWorktreeVisibility, + isLegacyRepoForExternalWorktreeVisibility +} from '../../../../../shared/worktree/ownership' + +export function getWorktreeVisibilityMenuLabel( + repo: Repo, + visibilityDefaults?: WorktreeVisibilityDefaults +): string { + const visibility = effectiveExternalWorktreeVisibility( + repo, + isLegacyRepoForExternalWorktreeVisibility(repo), + visibilityDefaults + ) + return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees' +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-visible-refresh-listener.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-visible-refresh-listener.ts new file mode 100644 index 00000000000..b9e77fefb83 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-visible-refresh-listener.ts @@ -0,0 +1,4 @@ +export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void { + document.addEventListener('visibilitychange', onChange) + return () => document.removeEventListener('visibilitychange', onChange) +} diff --git a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts index 933261f20a4..fb984cb2d54 100644 --- a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts +++ b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getScrollTopToRevealBounds } from './WorktreeList' +import { getScrollTopToRevealBounds } from './worktree-sidebar-reveal' describe('getScrollTopToRevealBounds', () => { const makeContainer = (scrollTop: number, clientHeight: number) => diff --git a/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts b/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts new file mode 100644 index 00000000000..91e970291fd --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts @@ -0,0 +1,24 @@ +export type WorktreeSnapshotPruneBatch = { + batchId: string + finish: () => Promise +} + +export function beginWorktreeSnapshotPruneBatch(): Promise | null { + if (typeof window === 'undefined') { + return null + } + const api = window.api.workspaceCleanup + const begin = api.beginRemovalSnapshotPruneBatch + const record = api.recordRemovalSnapshotPrune + const finish = api.finishRemovalSnapshotPruneBatch + if (typeof begin !== 'function' || typeof record !== 'function' || typeof finish !== 'function') { + return null + } + const batchId = crypto.randomUUID() + return begin({ batchId }) + .then(() => ({ batchId, finish: () => finish({ batchId }) })) + .catch((error: unknown) => { + console.warn('Failed to begin workspace snapshot prune batch:', error) + return null + }) +} diff --git a/src/renderer/src/components/sidebar/worktree-visibility-repo-sources.ts b/src/renderer/src/components/sidebar/worktree-visibility-repo-sources.ts new file mode 100644 index 00000000000..149a8ca3812 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-visibility-repo-sources.ts @@ -0,0 +1,31 @@ +import { useAppStore } from '@/store' +import type { CustomWorktreeVisibilitySource, Repo } from '../../../../shared/repo-types' +import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' +import { + normalizeCustomWorktreeVisibilitySources, + resolveCustomWorktreeVisibilitySources +} from '../../../../shared/worktree/visibility-sources' +import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' + +export function getLatestRepoForVisibilityScope(scope: string): Repo | null { + return useAppStore.getState().repos.find((repo) => getRepoHostIdentity(repo) === scope) ?? null +} + +export function getRepoCustomWorktreeVisibilitySourceIds(repo: Repo | null): Set { + return new Set( + normalizeCustomWorktreeVisibilitySources(repo?.customWorktreeVisibilitySources)?.map( + (source) => source.id + ) ?? [] + ) +} + +export function isDuplicateWorktreeVisibilitySource( + repo: Repo, + defaults: WorktreeVisibilityDefaults | undefined, + candidate: CustomWorktreeVisibilitySource +): boolean { + const current = resolveCustomWorktreeVisibilitySources(repo, defaults) + return ( + normalizeCustomWorktreeVisibilitySources([...current, candidate])?.length !== current.length + 1 + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-visibility-source-mutation.ts b/src/renderer/src/components/sidebar/worktree-visibility-source-mutation.ts new file mode 100644 index 00000000000..e722f44f347 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-visibility-source-mutation.ts @@ -0,0 +1,64 @@ +import type { ExternalWorktreeVisibility, Repo } from '../../../../shared/repo-types' +import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' +import { + effectiveExternalWorktreeVisibility, + isLegacyRepoForExternalWorktreeVisibility +} from '../../../../shared/external-worktree-visibility' +import { + effectiveBuiltInWorktreeSourceVisibility, + effectiveCustomWorktreeSourceVisibility +} from '../../../../shared/worktree/visibility-sources' +import { buildWorktreeSourcePreferenceUpdate } from '../../../../shared/worktree/visibility-source-preferences' +import type { RepoUpdate } from '@/store/slices/repos' +import type { WorktreeVisibilitySourceRow } from './WorktreeVisibilitySourceList' + +export type WorktreeVisibilitySourceMutation = { + updates: RepoUpdate + isAccepted: (latestRepo: Repo) => boolean +} + +/** Pins this project's own visibility for a source, overriding whatever Global Settings holds. */ +export function createWorktreeVisibilitySourceMutation( + repo: Repo, + source: WorktreeVisibilitySourceRow, + visibility: ExternalWorktreeVisibility, + visibilityDefaults: WorktreeVisibilityDefaults | undefined +): WorktreeVisibilitySourceMutation { + if (source.kind === 'other') { + return { + updates: { + externalWorktreeVisibility: visibility, + // Re-showing clears the suppression so discovery can surface the source again. + ...(visibility === 'show' ? { externalWorktreeDiscoverySuppressedAt: null } : {}) + }, + isAccepted: (latestRepo) => + effectiveExternalWorktreeVisibility( + latestRepo, + isLegacyRepoForExternalWorktreeVisibility(latestRepo), + visibilityDefaults + ) === visibility + } + } + const match = + source.kind === 'built-in' + ? ({ kind: 'built-in', id: source.id } as const) + : ({ kind: 'custom', id: source.source.id } as const) + return { + updates: { + worktreeVisibilitySourcePreferences: buildWorktreeSourcePreferenceUpdate( + repo, + match, + visibility + ) + }, + isAccepted: (latestRepo) => + source.kind === 'built-in' + ? effectiveBuiltInWorktreeSourceVisibility(latestRepo, source.id, visibilityDefaults) === + visibility + : effectiveCustomWorktreeSourceVisibility( + latestRepo, + source.source.id, + visibilityDefaults + ) === visibility + } +} diff --git a/src/renderer/src/components/sidebar/worktree-visibility-source-provenance.test.ts b/src/renderer/src/components/sidebar/worktree-visibility-source-provenance.test.ts new file mode 100644 index 00000000000..ad22d49f74d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-visibility-source-provenance.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + values + ? fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => String(values[name] ?? '')) + : fallback +})) + +const { + getWorktreeVisibilityOverrideNotice, + getWorktreeVisibilitySourceNote, + getWorktreeVisibilitySourceProvenance, + listInheritedWorktreeVisibilitySources +} = await import('./worktree-visibility-source-provenance') + +function repo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: '#000000', + addedAt: 0, + externalWorktreeVisibility: undefined, + ...overrides + } +} + +const shownClaude: WorktreeVisibilityDefaults = { + external: 'hide', + sourcePreferences: { builtIn: { claude: 'show' } } +} + +const NO_REPO_SOURCES: ReadonlySet = new Set() + +function provenanceFor(target: Repo, defaults: WorktreeVisibilityDefaults) { + return getWorktreeVisibilitySourceProvenance( + target, + { kind: 'built-in', id: 'claude' }, + defaults, + NO_REPO_SOURCES + ) +} + +describe('listInheritedWorktreeVisibilitySources', () => { + it('reports what each inheritable source is set to globally', () => { + expect(listInheritedWorktreeVisibilitySources(repo(), shownClaude)).toEqual([ + { source: { kind: 'built-in', id: 'claude' }, globalVisibility: 'show' }, + { source: { kind: 'built-in', id: 'gsd' }, globalVisibility: 'hide' }, + { source: { kind: 'other' }, globalVisibility: 'hide' } + ]) + }) + + it('keeps a source the project has overridden, since global is what it overrode', () => { + const overridden = listInheritedWorktreeVisibilitySources( + repo({ worktreeVisibilitySourcePreferences: { builtIn: { claude: 'hide' } } }), + shownClaude + ) + expect(overridden[0]).toEqual({ + source: { kind: 'built-in', id: 'claude' }, + globalVisibility: 'show' + }) + }) + + it('lists a global custom source but not one the project added itself', () => { + const listed = listInheritedWorktreeVisibilitySources( + repo({ customWorktreeVisibilitySources: [{ id: 'local', rootPath: '/srv/local' }] }), + { + external: 'hide', + customSources: [{ id: 'team', rootPath: '/srv/team' }], + sourcePreferences: { custom: { team: 'show' } } + } + ) + expect(listed.map(({ source }) => source)).toContainEqual({ + kind: 'custom', + source: { id: 'team', rootPath: '/srv/team' } + }) + expect( + listed.some(({ source }) => source.kind === 'custom' && source.source.id === 'local') + ).toBe(false) + }) +}) + +describe('getWorktreeVisibilityOverrideNotice', () => { + it('names the global value a project override is ignoring', () => { + const provenance = provenanceFor( + repo({ worktreeVisibilitySourcePreferences: { builtIn: { claude: 'hide' } } }), + shownClaude + ) + expect(getWorktreeVisibilityOverrideNotice(provenance, 'hide')).toBe( + 'Overriding global setting: Show' + ) + }) + + it('stays quiet for an override that agrees with global settings', () => { + // Why: a legacy repo materializes an override on every source, most of which change nothing. + const provenance = provenanceFor(repo({ agentWorktreeVisibility: 'show' }), shownClaude) + expect(provenance?.kind).toBe('project-override') + expect(getWorktreeVisibilityOverrideNotice(provenance, 'show')).toBeNull() + }) + + it('stays quiet for a source that is still following global settings', () => { + expect( + getWorktreeVisibilityOverrideNotice(provenanceFor(repo(), shownClaude), 'show') + ).toBeNull() + }) + + it('stays quiet outside a project scope', () => { + expect(getWorktreeVisibilityOverrideNotice(null, 'show')).toBeNull() + }) +}) + +describe('getWorktreeVisibilitySourceNote', () => { + it('marks a source the project added itself', () => { + const provenance = getWorktreeVisibilitySourceProvenance( + repo({ customWorktreeVisibilitySources: [{ id: 'local', rootPath: '/srv/local' }] }), + { kind: 'custom', source: { id: 'local', rootPath: '/srv/local' } }, + { external: 'hide' }, + new Set(['local']) + ) + expect(getWorktreeVisibilitySourceNote(provenance)).toBe('Added in this project only.') + }) + + it('says nothing for an inherited source', () => { + expect(getWorktreeVisibilitySourceNote(provenanceFor(repo(), shownClaude))).toBeNull() + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-visibility-source-provenance.ts b/src/renderer/src/components/sidebar/worktree-visibility-source-provenance.ts new file mode 100644 index 00000000000..19410c2103e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-visibility-source-provenance.ts @@ -0,0 +1,126 @@ +import { translate } from '@/i18n/i18n' +import type { ExternalWorktreeVisibility, Repo } from '../../../../shared/repo-types' +import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' +import { effectiveExternalWorktreeVisibility } from '../../../../shared/external-worktree-visibility' +import { + effectiveDefaultBuiltInWorktreeSourceVisibility, + effectiveDefaultCustomWorktreeSourceVisibility, + normalizeCustomWorktreeVisibilitySources, + normalizeWorktreeVisibilitySourcePreferences +} from '../../../../shared/worktree/visibility-sources' +import type { WorktreeVisibilitySourceRow } from './WorktreeVisibilitySourceList' + +export type WorktreeVisibilitySourceProvenance = { + kind: 'global' | 'project-override' | 'project-source' + globalVisibility: ExternalWorktreeVisibility +} + +/** What this source resolves to for a project that has no opinion of its own. */ +export function globalWorktreeVisibilitySourceValue( + source: WorktreeVisibilitySourceRow, + visibilityDefaults: WorktreeVisibilityDefaults | undefined +): ExternalWorktreeVisibility { + if (source.kind === 'built-in') { + return effectiveDefaultBuiltInWorktreeSourceVisibility(visibilityDefaults, source.id) + } + if (source.kind === 'custom') { + return effectiveDefaultCustomWorktreeSourceVisibility(visibilityDefaults, source.source.id) + } + return effectiveExternalWorktreeVisibility({}, false, visibilityDefaults) +} + +export function getWorktreeVisibilitySourceProvenance( + repo: Repo | undefined, + source: WorktreeVisibilitySourceRow, + visibilityDefaults: WorktreeVisibilityDefaults, + repoCustomSourceIds: ReadonlySet +): WorktreeVisibilitySourceProvenance | null { + if (!repo) { + return null + } + const globalVisibility = globalWorktreeVisibilitySourceValue(source, visibilityDefaults) + if (source.kind === 'custom' && repoCustomSourceIds.has(source.source.id)) { + return { kind: 'project-source', globalVisibility } + } + const preferences = normalizeWorktreeVisibilitySourcePreferences( + repo.worktreeVisibilitySourcePreferences + ) + const overridden = + source.kind === 'built-in' + ? preferences?.builtIn?.[source.id] !== undefined || + repo.agentWorktreeVisibility !== undefined + : source.kind === 'custom' + ? preferences?.custom?.[source.source.id] !== undefined + : repo.externalWorktreeVisibility !== undefined + return { kind: overridden ? 'project-override' : 'global', globalVisibility } +} + +/** Sources whose value this project inherits from Global Settings, with what each is set to. */ +export function listInheritedWorktreeVisibilitySources( + repo: Repo, + visibilityDefaults: WorktreeVisibilityDefaults | undefined +): { source: WorktreeVisibilitySourceRow; globalVisibility: ExternalWorktreeVisibility }[] { + const defaults = visibilityDefaults ?? {} + const repoCustomSourceIds = new Set( + normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources)?.map( + (source) => source.id + ) ?? [] + ) + const sources: WorktreeVisibilitySourceRow[] = [ + { kind: 'built-in', id: 'claude' }, + { kind: 'built-in', id: 'gsd' }, + ...(normalizeCustomWorktreeVisibilitySources(defaults.customSources) ?? []).map((source) => ({ + kind: 'custom' as const, + source + })), + { kind: 'other' } + ] + + return sources.flatMap((source) => { + const provenance = getWorktreeVisibilitySourceProvenance( + repo, + source, + defaults, + repoCustomSourceIds + ) + // Why: a source the project added itself has no global setting behind it to override. + return !provenance || provenance.kind === 'project-source' + ? [] + : [{ source, globalVisibility: provenance.globalVisibility }] + }) +} + +export function worktreeVisibilityValueLabel(visibility: ExternalWorktreeVisibility): string { + return visibility === 'show' + ? translate('auto.components.sidebar.WorktreeVisibilitySourceList.show', 'Show') + : translate('auto.components.sidebar.WorktreeVisibilitySourceList.hide', 'Hide') +} + +/** + * Names the global value a project is ignoring — only where the two genuinely disagree, + * so an override that happens to match Global Settings stays quiet. + */ +export function getWorktreeVisibilityOverrideNotice( + provenance: WorktreeVisibilitySourceProvenance | null, + visibility: ExternalWorktreeVisibility +): string | null { + if (provenance?.kind !== 'project-override' || provenance.globalVisibility === visibility) { + return null + } + return translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.overridingGlobal', + 'Overriding global setting: {{value0}}', + { value0: worktreeVisibilityValueLabel(provenance.globalVisibility) } + ) +} + +export function getWorktreeVisibilitySourceNote( + provenance: WorktreeVisibilitySourceProvenance | null +): string | null { + return provenance?.kind === 'project-source' + ? translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.projectOnly', + 'Added in this project only.' + ) + : null +} diff --git a/src/renderer/src/components/sidebar/worktree-visibility-use-global.ts b/src/renderer/src/components/sidebar/worktree-visibility-use-global.ts new file mode 100644 index 00000000000..cffea3e8308 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-visibility-use-global.ts @@ -0,0 +1,90 @@ +import type { ExternalWorktreeVisibility, Repo } from '../../../../shared/repo-types' +import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' +import { + effectiveExternalWorktreeVisibility, + isLegacyRepoForExternalWorktreeVisibility +} from '../../../../shared/external-worktree-visibility' +import { + effectiveBuiltInWorktreeSourceVisibility, + effectiveCustomWorktreeSourceVisibility, + normalizeWorktreeVisibilitySourcePreferences +} from '../../../../shared/worktree/visibility-sources' +import { + removeBuiltInWorktreeSourcePreference, + removeCustomWorktreeSourcePreference +} from '../../../../shared/worktree/visibility-source-preferences' +import type { WorktreeVisibilitySourceRow } from './WorktreeVisibilitySourceList' +import type { WorktreeVisibilitySourceMutation } from './worktree-visibility-source-mutation' +import { + getWorktreeVisibilitySourceProvenance, + globalWorktreeVisibilitySourceValue +} from './worktree-visibility-source-provenance' + +/** + * Why: picking the value Global Settings already holds drops the project's override rather than + * pinning a duplicate of it, so the same control both overrides and reverts (#14276). + */ +export function shouldUseGlobalWorktreeVisibility( + repo: Repo, + source: WorktreeVisibilitySourceRow, + visibility: ExternalWorktreeVisibility, + visibilityDefaults: WorktreeVisibilityDefaults | undefined, + repoCustomSourceIds: ReadonlySet +): boolean { + const provenance = getWorktreeVisibilitySourceProvenance( + repo, + source, + visibilityDefaults ?? {}, + repoCustomSourceIds + ) + return ( + provenance?.kind === 'project-override' && + globalWorktreeVisibilitySourceValue(source, visibilityDefaults) === visibility + ) +} + +export function createWorktreeVisibilityUseGlobalMutation( + repo: Repo, + source: WorktreeVisibilitySourceRow, + visibilityDefaults: WorktreeVisibilityDefaults | undefined +): WorktreeVisibilitySourceMutation { + if (source.kind === 'other') { + return { + updates: { externalWorktreeVisibility: null }, + isAccepted: (latestRepo) => + latestRepo.externalWorktreeVisibility === undefined && + effectiveExternalWorktreeVisibility( + latestRepo, + isLegacyRepoForExternalWorktreeVisibility(latestRepo), + visibilityDefaults + ) === effectiveExternalWorktreeVisibility({}, false, visibilityDefaults) + } + } + if (source.kind === 'built-in') { + return { + updates: { + agentWorktreeVisibility: null, + worktreeVisibilitySourcePreferences: removeBuiltInWorktreeSourcePreference(repo, source.id) + }, + isAccepted: (latestRepo) => + latestRepo.agentWorktreeVisibility === undefined && + normalizeWorktreeVisibilitySourcePreferences(latestRepo.worktreeVisibilitySourcePreferences) + ?.builtIn?.[source.id] === undefined && + effectiveBuiltInWorktreeSourceVisibility(latestRepo, source.id, visibilityDefaults) === + effectiveBuiltInWorktreeSourceVisibility({}, source.id, visibilityDefaults) + } + } + return { + updates: { + worktreeVisibilitySourcePreferences: removeCustomWorktreeSourcePreference( + repo, + source.source.id + ) + }, + isAccepted: (latestRepo) => + normalizeWorktreeVisibilitySourcePreferences(latestRepo.worktreeVisibilitySourcePreferences) + ?.custom?.[source.source.id] === undefined && + effectiveCustomWorktreeSourceVisibility(latestRepo, source.source.id, visibilityDefaults) === + effectiveCustomWorktreeSourceVisibility({}, source.source.id, visibilityDefaults) + } +} diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 688d59c9a14..6ddfef2251c 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -36,7 +36,6 @@ import type { AppMemory, UsageValues } from '../../../../shared/process-stats-ty import type { Worktree } from '../../../../shared/worktree/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import { countEstimatedInactiveWorkspaces } from '../workspace-cleanup/inactive-workspace-estimate' import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' import type { Metric, @@ -909,17 +908,11 @@ export function ResourceUsageStatusSegment({ return map }, [repos]) - const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) const worktreeById = useMemo( () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])), [allWorktrees] ) - const oldWorkspaceCount = useMemo( - () => countEstimatedInactiveWorkspaces(allWorktrees, repoById, Date.now()), - [allWorktrees, repoById] - ) - // Why: skip the merge when closed; the always-mounted segment recomputing on every keystroke-driven store mutation made the app laggy. const unifiedRepos = useMemo( () => @@ -1482,8 +1475,7 @@ export function ResourceUsageStatusSegment({ {translate( 'auto.components.status.bar.ResourceUsageStatusSegment.92924a14e3', - 'Review inactive workspaces ({{value0}})', - { value0: oldWorkspaceCount } + 'Clean up workspaces' )} 0 + ? { + name: translate( + 'components.status.bar.workspaceSpace.otherTopLevelItems', + 'Other top-level items ({{value0}})', + { value0: worktree.omittedTopLevelItemCount } + ), + path: '', + kind: 'other', + sizeBytes: worktree.omittedTopLevelSizeBytes + } + : null return (
@@ -1010,7 +1026,7 @@ function BreakdownList({ )}
- ) : worktree.topLevelItems.length === 0 ? ( + ) : topLevelItemCount === 0 ? (
{translate( 'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079', @@ -1023,6 +1039,7 @@ function BreakdownList({ {worktree.topLevelItems.slice(0, 12).map((item) => ( ))} + {omittedItem ? : null}
)} diff --git a/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts index d17155f9d45..d827c7e232d 100644 --- a/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts +++ b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts @@ -133,7 +133,7 @@ async function revealFromSnapshot( // The pending escape tail must be the FINAL replay write (mirrors applyMainBufferSnapshot); a later ESC would abort the dangling sequence. const preamble = alt && snapshot.scrollbackAnsi !== undefined - ? `\x1b[?1049l\x1b[2J\x1b[3J\x1b[H${snapshot.scrollbackAnsi}${SNAPSHOT_REPLAY_PREAMBLE_ALT}` + ? `${SNAPSHOT_REPLAY_PREAMBLE_NORMAL}${snapshot.scrollbackAnsi}${SNAPSHOT_REPLAY_PREAMBLE_ALT}` : alt ? SNAPSHOT_REPLAY_PREAMBLE_ALT : SNAPSHOT_REPLAY_PREAMBLE_NORMAL diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 12940d28ce9..c26f80347a7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -9,10 +9,14 @@ import { POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, + ABORT_TRUNCATED_CONTROL_STRING, + buildSnapshotReplayPrologue, RESET_AFTER_BYTE_GAP, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' + +const NORMAL_BUFFER_PROLOGUE = `${ABORT_TRUNCATED_CONTROL_STRING}${buildSnapshotReplayPrologue({ targetAlternateScreen: false, paneOnAlternateScreen: false })}` import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' import { DEFAULT_DA1_RESPONSE } from './terminal-capability-replies' import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator' @@ -497,7 +501,9 @@ function createPaneContainer(): HTMLElement { function createPane(paneId: number) { const leafId = leafIdForPane(paneId) const activeBuffer = { - type: 'normal' as const, + // Mutable so a test can put the pane on the alt screen: the replay prologue + // only switches buffers when this disagrees with the snapshot. + type: 'normal' as 'normal' | 'alternate', viewportY: 0, baseY: 0, cursorY: 0, @@ -10231,7 +10237,7 @@ describe('connectPanePty', () => { claim: true }) expect(written).toContain(viewportClear) - expect(written).not.toContain(`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`) + expect(written).not.toContain(NORMAL_BUFFER_PROLOGUE) expect(written).toEqual( expect.arrayContaining([coldScrollback, POST_REPLAY_MODE_RESET, blankViewport]) ) @@ -15276,6 +15282,10 @@ describe('connectPanePty', () => { 4 ) expect(actual).toEqual(expected) + // The imageless snapshot skips the repaint, but the gap still happened — so + // the pen is grounded anyway, with the live-path constant since nothing + // repaints over whatever is still running here. + expect(restoreWrites).toContain(RESET_AFTER_BYTE_GAP) expect(transport.serializeBufferOutcome).toHaveBeenCalledTimes(1) expect(transport.serializeBuffer).not.toHaveBeenCalled() disposable.dispose() @@ -15462,6 +15472,56 @@ describe('connectPanePty', () => { disposable.dispose() }) + // The conditional buffer switch is the riskiest logic in the replay prologue, + // and the pane mock is on the normal buffer everywhere else — so a stubbed + // `() => false` would pass the whole suite. Put the pane on the alt screen and + // pin that a normal-buffer snapshot really does emit the `?1049l` unstick. + it('emits the alt-screen unstick when the pane is on alt and the model is not', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + // The gap ate the TUI's own `?1049l`, so the renderer is still on alt. + pane.terminal.buffer.active.type = 'alternate' + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + const written = pane.terminal.write.mock.calls.map(([data]) => data as string) + const prologue = written.find((data) => data.startsWith(ABORT_TRUNCATED_CONTROL_STRING)) + expect(prologue).toBeDefined() + expect(prologue).toContain('\x1b[?1049l') + expect(prologue).not.toBe(NORMAL_BUFFER_PROLOGUE) + disposable.dispose() + }) + // Why: pins the switch-off hidden fallback chain — 2MB lossy cap drops the backlog, latches restore, and reveal repaints from the model snapshot. it('restores hidden backlog overflow from the main terminal snapshot on foreground output', async () => { const { connectPanePty } = await import('./pty-connection') @@ -15506,10 +15566,7 @@ describe('connectPanePty', () => { expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) expect(pane.terminal.resize).toHaveBeenCalledWith(100, 30) - expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, - expect.any(Function) - ) + expect(pane.terminal.write).toHaveBeenCalledWith(NORMAL_BUFFER_PROLOGUE, expect.any(Function)) expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function)) expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) disposable.dispose() @@ -15559,27 +15616,28 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) - expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, - expect.any(Function) - ) + expect(pane.terminal.write).toHaveBeenCalledWith(NORMAL_BUFFER_PROLOGUE, expect.any(Function)) expect(pane.terminal.write).toHaveBeenCalledWith( 'preserved-shell-history\r\n', expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, + buildSnapshotReplayPrologue({ targetAlternateScreen: true, paneOnAlternateScreen: false }), expect.any(Function) ) const writes = (pane.terminal.write as ReturnType).mock.calls.map( (call) => call[0] ) expect(writes.indexOf('preserved-shell-history\r\n')).toBeLessThan( - writes.indexOf(`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`) - ) - expect(writes.indexOf(`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`)).toBeLessThan( - writes.indexOf('altscreen-snapshot\r\n') + writes.indexOf( + buildSnapshotReplayPrologue({ targetAlternateScreen: true, paneOnAlternateScreen: false }) + ) ) + expect( + writes.indexOf( + buildSnapshotReplayPrologue({ targetAlternateScreen: true, paneOnAlternateScreen: false }) + ) + ).toBeLessThan(writes.indexOf('altscreen-snapshot\r\n')) expect(pane.terminal.write).toHaveBeenCalledWith('altscreen-snapshot\r\n', expect.any(Function)) disposable.dispose() }) @@ -15842,6 +15900,9 @@ describe('connectPanePty', () => { const combinedLiveIndex = written.indexOf(firstLive + secondLive) expect(warningIndex).toBeGreaterThanOrEqual(0) expect(written[warningIndex - 1]).toBe(RESET_AFTER_BYTE_GAP) + // Exactly one: writeRestoreUnavailableWarning already grounds the gap, so a + // second unconditional write here was pure duplication. + expect(written.filter((data) => data === RESET_AFTER_BYTE_GAP)).toHaveLength(1) expect(combinedLiveIndex).toBeGreaterThan(warningIndex) snapshot.resolve({ @@ -15955,6 +16016,9 @@ describe('connectPanePty', () => { const liveIndex = written.indexOf(live) expect(resetIndex).toBeGreaterThanOrEqual(0) expect(liveIndex).toBeGreaterThan(resetIndex) + // The re-arm arm grounds in rearmRemoteHiddenOutputRestoreInsteadOfWarning, + // so the abandon body must not ground a second time. + expect(written.filter((data) => data === RESET_AFTER_BYTE_GAP)).toHaveLength(1) expect(written.join('')).not.toContain('main recovery was unavailable') disposable.dispose() @@ -16010,6 +16074,10 @@ describe('connectPanePty', () => { expect(getMainBufferSnapshot).toHaveBeenCalledTimes(4) expect(warningIndex).toBeGreaterThanOrEqual(0) expect(liveIndex).toBeGreaterThan(warningIndex) + // This arm gives up on recovery too, so the gap is grounded exactly once + // before the blocked foreground is drained under it. + expect(written.filter((data) => data === RESET_AFTER_BYTE_GAP)).toHaveLength(1) + expect(written.indexOf(RESET_AFTER_BYTE_GAP)).toBeLessThan(liveIndex) disposable.dispose() }) @@ -16476,7 +16544,7 @@ describe('connectPanePty', () => { expect(pane.terminal.clear).toHaveBeenCalled() expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) expect(pane.terminal.write).not.toHaveBeenCalledWith( - `${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, + NORMAL_BUFFER_PROLOGUE, expect.any(Function) ) disposable.dispose() @@ -17374,7 +17442,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(6) expect(pane.terminal.write).not.toHaveBeenCalledWith( - `${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, + NORMAL_BUFFER_PROLOGUE, expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index c4e67e6f1f8..dacd830c9e0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -6387,6 +6387,20 @@ export function connectPanePty( liveScrollbackRestore?.dispose() liveScrollbackRestore = installTerminalLiveScrollbackRestore(pane.terminal) + // Why read it live: `?1049` is not a no-op when the pane is already on the + // target buffer — xterm still runs restoreCursor and swaps the kitty flag + // registers — so the replay prologue must only switch when it truly differs. + // + // Best-effort by design. xterm parses asynchronously, so this sees only + // PARSED writes; a `?1049` transition still queued reads stale. Draining + // first with a write sentinel was tried and reverted: it puts the repaint + // behind xterm's queue, so a wedged terminal stalls recovery, and it breaks + // the disposal/cancellation ordering the restore paths rely on. A stale read + // costs one mis-scoped buffer switch; the barrier costs the repaint. + function isPaneOnAlternateScreen(): boolean { + return pane.terminal.buffer.active.type === 'alternate' + } + function writePtyOutputToXterm( data: string, foreground: boolean, @@ -7087,11 +7101,11 @@ export function connectPanePty( clearHiddenOutputRestoreFloodRepaintTimer() writeRestoreUnavailableWarning() } - // Why not an else: the unavailable warning is plain text and carries no SGR - // of its own, so folding the reset into the other branch skipped it on the - // primary abandon path — the one that declares the bytes unrecoverable. - // Guarded only against the remote re-arm, which writes its own reset. - if (!rearmedRemoteRestore) { + // Why an else: the branch above already grounds the gap inside + // writeRestoreUnavailableWarning, and the remote re-arm grounds it in + // rearmRemoteHiddenOutputRestoreInsteadOfWarning. Only the quiet + // flood-abandon reaches neither, and it still drains chunks below. + else if (!rearmedRemoteRestore) { writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) } if (hadPendingOverflow) { @@ -7323,9 +7337,16 @@ export function connectPanePty( // Why: an imageless success is not proof the pane is empty; normal replay clears screen and scrollback. const snapshotCarriesNoImage = snapshot.alternateScreen !== true && snapshot.data === '' && !snapshot.scrollbackAnsi - if (!snapshotCarriesNoImage) { + if (snapshotCarriesNoImage) { + // Why still ground: a restore only runs because bytes were dropped, so + // the gap's pen outlives a snapshot that cannot repaint over it. The + // live-path constant, not the replay baseline — nothing repaints here, + // so the pane is handed back to whatever is still running. + writeReplayData(RESET_AFTER_BYTE_GAP) + } else { for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { - skipAltFrame: skippedAltFrame + skipAltFrame: skippedAltFrame, + paneOnAlternateScreen: isPaneOnAlternateScreen() })) { writeReplayData(replayChunk) } @@ -8037,7 +8058,9 @@ export function connectPanePty( snapshotSeq: snapshot.seq }) // Why keep a too-wide frame: preconnect SSH has no live repaint owner or post-restore fit. - for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot)) { + for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { + paneOnAlternateScreen: isPaneOnAlternateScreen() + })) { writeReplayData(replayChunk) } writeReplayData(reattachReplayResetSequence(modelData)) @@ -8324,7 +8347,8 @@ export function connectPanePty( skipAltFrame: shouldSkipAltFrameForWidthMismatch( modelCols, readProposedTerminalCols(pane) - ) + ), + paneOnAlternateScreen: isPaneOnAlternateScreen() })) { writeReplayData(replayChunk) } diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts index 461e95edfed..19b22971b41 100644 --- a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts @@ -8,7 +8,8 @@ import { drainPreHandlerPtyData, drainPreHandlerPtyExit, discardPreHandlerPtyState, - hasPreHandlerPtyExit + hasPreHandlerPtyExit, + replayPreHandlerPtyData } from './pty-pre-handler-buffer' const RESCAN_PTY_ID = 'pty-pre-handler-rescan' @@ -53,6 +54,22 @@ describe('pre-handler PTY buffer', () => { expect(drained).toHaveLength(4_096) }) + it('replays startup bytes without consuming the primary handler drain', () => { + bufferPreHandlerPtyData(RESCAN_PTY_ID, 'composer frame') + bufferPreHandlerPtyData(RESCAN_PTY_ID, '\x1b[?2004h') + const observer = vi.fn() + const primary = vi.fn() + + replayPreHandlerPtyData(RESCAN_PTY_ID, observer) + drainPreHandlerPtyData(RESCAN_PTY_ID, primary) + + expect(observer.mock.calls).toEqual([['composer frame'], ['\x1b[?2004h']]) + expect(primary.mock.calls).toEqual([ + ['composer frame', undefined], + ['\x1b[?2004h', undefined] + ]) + }) + it('does not shift the live array while trimming a capped backlog', () => { const originalShift = Array.prototype.shift const originalWarn = console.warn diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts index eca3b7644b9..46171d40fda 100644 --- a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts @@ -96,6 +96,17 @@ export function drainPreHandlerPtyData( } } +/** Replay buffered startup bytes without taking them from the future primary handler. */ +export function replayPreHandlerPtyData(ptyId: string, observer: (data: string) => void): void { + const state = preHandlerPtyData.get(ptyId) + if (!state) { + return + } + for (let index = state.head; index < state.chunks.length; index += 1) { + observer(state.chunks[index].data) + } +} + export function bufferPreHandlerPtyExit(ptyId: string, code: number): void { if (consumedPreHandlerPtyExits.has(ptyId) || discardedPreHandlerPtyStates.has(ptyId)) { return diff --git a/src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.test.ts b/src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.test.ts new file mode 100644 index 00000000000..a788eaefc2c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { + isNonLatinControlChordKeyup, + resolveNonLatinControlChordInput, + type NonLatinControlChordEvent +} from './terminal-non-latin-control-chord' + +function event(overrides: Partial): NonLatinControlChordEvent { + return { + type: 'keydown', + key: 'ㅁ', + code: 'KeyA', + ctrlKey: true, + altKey: false, + metaKey: false, + shiftKey: false, + ...overrides + } +} + +describe('resolveNonLatinControlChordInput', () => { + // Measured on macOS with UCKeyTranslate: physical A/U/C under Control produce + // U+0001/U+0015/U+0003 on 2SetHangul, Russian and Greek alike, matching ABC. + it.each([ + ['Korean 2-Set', 'ㅁ', 'KeyA', '\x01'], + ['Korean 2-Set', 'ㅕ', 'KeyU', '\x15'], + ['Russian', 'ф', 'KeyA', '\x01'], + ['Russian', 'г', 'KeyU', '\x15'], + ['Greek', 'α', 'KeyA', '\x01'], + ['Greek', 'θ', 'KeyU', '\x15'], + ['Hangul syllable', '가', 'KeyD', '\x04'] + ])('%s: Ctrl+%s on %s sends %j', (_layout, key, code, expected) => { + expect(resolveNonLatinControlChordInput(event({ key, code }))).toBe(expected) + }) + + it('covers the whole letter range, not just the reported keys', () => { + for (let index = 0; index < 26; index++) { + const letter = String.fromCharCode(0x41 + index) + if (letter === 'C') { + continue + } + expect(resolveNonLatinControlChordInput(event({ code: `Key${letter}` }))).toBe( + String.fromCharCode(index + 1) + ) + } + }) + + // Ctrl+C is the interrupt policy's, and that policy declines off macOS when there is a + // selection so the copy binding wins. Claiming C here would send ETX in exactly that case. + it('leaves Ctrl+C to the interrupt policy', () => { + expect(resolveNonLatinControlChordInput(event({ key: 'ㅊ', code: 'KeyC' }))).toBeNull() + }) + + // An ASCII logical key is authoritative: a Dvorak remap really did move the letter. + it('leaves an ASCII logical key to xterm', () => { + expect(resolveNonLatinControlChordInput(event({ key: 'a', code: 'KeyA' }))).toBeNull() + expect(resolveNonLatinControlChordInput(event({ key: 'j', code: 'KeyC' }))).toBeNull() + }) + + it('ignores chords that are not a plain Ctrl press', () => { + expect(resolveNonLatinControlChordInput(event({ ctrlKey: false }))).toBeNull() + expect(resolveNonLatinControlChordInput(event({ altKey: true }))).toBeNull() + expect(resolveNonLatinControlChordInput(event({ metaKey: true }))).toBeNull() + // Ctrl+Shift has its own encoding; rewriting it would change a correct kitty report. + expect(resolveNonLatinControlChordInput(event({ shiftKey: true }))).toBeNull() + expect(resolveNonLatinControlChordInput(event({ type: 'keyup' }))).toBeNull() + }) + + it('ignores physical keys that are not letters', () => { + // Digits and punctuation stay ASCII in `key` on these layouts, so xterm still sees them. + expect(resolveNonLatinControlChordInput(event({ key: '2', code: 'Digit2' }))).toBeNull() + expect(resolveNonLatinControlChordInput(event({ code: 'BracketLeft' }))).toBeNull() + expect(resolveNonLatinControlChordInput(event({ code: undefined }))).toBeNull() + }) + + it('ignores named and empty keys', () => { + expect(resolveNonLatinControlChordInput(event({ key: 'Enter' }))).toBeNull() + // 'Process' is what some input sources report while an IME owns the key. + expect(resolveNonLatinControlChordInput(event({ key: 'Process' }))).toBeNull() + expect(resolveNonLatinControlChordInput(event({ key: '' }))).toBeNull() + }) + + // Any single non-ASCII codepoint on a letter key resolves positionally, astral included. + // No layout puts an emoji on KeyA, so this is unreachable in practice — it is pinned to + // record that the rule is "not ASCII" rather than a script allowlist that would need + // extending for every new writing system. + it('treats any single non-ASCII codepoint uniformly', () => { + expect(resolveNonLatinControlChordInput(event({ key: '😀' }))).toBe('\x01') + expect(resolveNonLatinControlChordInput(event({ key: 'ア' }))).toBe('\x01') + expect(resolveNonLatinControlChordInput(event({ key: 'א' }))).toBe('\x01') + }) +}) + +describe('isNonLatinControlChordKeyup', () => { + it('matches the claimed press by physical key', () => { + expect(isNonLatinControlChordKeyup(event({ type: 'keyup' }), 'KeyA')).toBe(true) + // Ctrl may already be up by the time the letter is released. + expect(isNonLatinControlChordKeyup(event({ type: 'keyup', ctrlKey: false }), 'KeyA')).toBe(true) + }) + + it('does not match a different key or an unclaimed press', () => { + expect(isNonLatinControlChordKeyup(event({ type: 'keyup', code: 'KeyB' }), 'KeyA')).toBe(false) + expect(isNonLatinControlChordKeyup(event({ type: 'keyup' }), null)).toBe(false) + expect(isNonLatinControlChordKeyup(event({ type: 'keydown' }), 'KeyA')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.ts b/src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.ts new file mode 100644 index 00000000000..a626468ba37 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.ts @@ -0,0 +1,92 @@ +/** + * Control chords on a non-Latin keyboard layout. + * + * macOS resolves these positionally: measured with `UCKeyTranslate`, physical C/A/U under + * Control produce U+0003/U+0001/U+0015 on 2SetHangul, Russian and Greek exactly as they do + * on ABC, even though the same keys unmodified produce `ㅊ`/`с`/`ψ`. A native terminal gets + * this for free by passing the OS-provided characters through. + * + * The browser does not expose that translation. `KeyboardEvent.key` carries the layout's + * glyph, so anything matching on `key` sees `ㅁ` for Ctrl+A and cannot recognise the chord. + * xterm's legacy encoder sidesteps it by reading `keyCode` (65-90), which Chromium reports + * from the physical key — but its kitty encoder derives the key number from `key`, and only + * consults `code` when Shift or Option is held. Ctrl is not in that gate, so a pane with the + * kitty protocol negotiated reports CSI-u for U+3141 instead of `a` and the chord does + * nothing (#13331). Ctrl+C escaped this only because it has a hand-written ETX bypass. + * + * Recovering the byte from `code` reproduces what the OS itself would have produced. + */ + +/** Only a non-ASCII `key` is ambiguous. A Latin layout that moves letters (Dvorak) reports a + * real ASCII letter, and that letter is authoritative — never override it. */ +function hasNonAsciiLogicalKey(key: string): boolean { + if (Array.from(key).length !== 1) { + return false + } + return (key.codePointAt(0) ?? 0) > 0x7f +} + +/** + * `KeyA`-`KeyZ` except `KeyC`. Digits and punctuation stay ASCII in `key` on these layouts, + * so they reach xterm intact and are not this module's to touch. + * + * `KeyC` is excluded because Ctrl+C is not a plain control chord: off macOS it must yield to + * a selection so the copy binding wins, and it resets the pane's kitty flags because a CLI + * can die on SIGINT before restoring them. The interrupt policy owns all of that and runs + * first; claiming C here would send ETX in the one case that policy deliberately declines. + */ +function physicalLetterFromCode(code: string | undefined): string | null { + if (!code || code.length !== 4 || !code.startsWith('Key')) { + return null + } + const letter = code.charAt(3) + if (letter === 'C') { + return null + } + return letter >= 'A' && letter <= 'Z' ? letter : null +} + +export type NonLatinControlChordEvent = { + type: string + key: string + code?: string + ctrlKey: boolean + altKey: boolean + metaKey: boolean + shiftKey: boolean +} + +/** + * The C0 byte a plain Ctrl chord should send, or null when this event is not one. + * + * Shift is excluded deliberately: Ctrl+Shift chords have their own encoding, and rewriting + * them here would change what a kitty pane reports for a key it already handles correctly. + */ +export function resolveNonLatinControlChordInput(event: NonLatinControlChordEvent): string | null { + if (event.type !== 'keydown') { + return null + } + if (!event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + return null + } + if (!hasNonAsciiLogicalKey(event.key)) { + return null + } + const letter = physicalLetterFromCode(event.code) + if (!letter) { + return null + } + // 'A' -> 0x01 ... 'Z' -> 0x1a, the same arithmetic the OS control table applies. + return String.fromCharCode(letter.charCodeAt(0) - 0x40) +} + +/** The matching keyup, so a kitty release report for the swallowed press cannot leak. */ +export function isNonLatinControlChordKeyup( + event: NonLatinControlChordEvent, + claimedCode: string | null +): boolean { + if (event.type !== 'keyup' || !claimedCode) { + return false + } + return event.code === claimedCode +} diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-application-continuity.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-application-continuity.test.ts new file mode 100644 index 00000000000..418ebe57d31 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-replay-application-continuity.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { + buildParityMainBufferSnapshot, + createRendererParityTerminal, + writeToTerminal +} from '../../../../shared/terminal-restore-parity-fixture' +import { buildMainModelSnapshotReplayWrites } from './terminal-snapshot-replay-paint' + +/** + * What a LIVE application sees after its pane is restored from a main-model + * snapshot. The sibling suite proves the restored frame is correct; this one + * proves the replay does not confiscate state the application still owns and + * will not re-declare. + * + * Grounded in real capability strings (`infocmp`), because the one regression + * that reached review here was a terminfo family nobody had enumerated. + */ + +async function replayInto( + terminal: Terminal, + model: ReturnType, + paneOnAlternateScreen: boolean +): Promise { + const snapshot = buildParityMainBufferSnapshot(model, 1) + for (const write of buildMainModelSnapshotReplayWrites(snapshot, { paneOnAlternateScreen })) { + await writeToTerminal(terminal, write) + } +} + +function row(terminal: Terminal, index: number): string { + return terminal.buffer.active.getLine(index)?.translateToString(true) ?? '' +} + +/** Text alone cannot see a stranded pen: include the attributes per cell. */ +function rowWithAttributes(terminal: Terminal, index: number): string { + const line = terminal.buffer.active.getLine(index) + if (!line) { + return '' + } + const cells: string[] = [] + for (let column = 0; column < line.length; column++) { + const cell = line.getCell(column) + if (!cell) { + continue + } + cells.push( + `${cell.getChars()}|${cell.isBold() ? 'b' : ''}${cell.isInverse() ? 'i' : ''}${cell.getFgColor()}` + ) + } + return cells.join(',') +} + +describe('line-drawing survives a replay for every real terminfo strategy', () => { + // Verbatim from `infocmp`. The two families differ in who owns the + // designation: xterm/alacritty re-designate G0 on every smacs, so they + // self-heal; the SO/SI family designates G1 once via enacs and never again. + const acsStrategies = [ + { term: 'xterm-256color', enacs: '', smacs: '\x1b(0', rmacs: '\x1b(B' }, + { term: 'alacritty', enacs: '', smacs: '\x1b(0', rmacs: '\x1b(B' }, + { term: 'screen-256color', enacs: '\x1b(B\x1b)0', smacs: '\x0e', rmacs: '\x0f' }, + { term: 'tmux-256color', enacs: '\x1b(B\x1b)0', smacs: '\x0e', rmacs: '\x0f' }, + { term: 'vt100', enacs: '\x1b(B\x1b)0', smacs: '\x0e', rmacs: '\x0f' }, + { term: 'linux', enacs: '\x1b)0', smacs: '\x0e', rmacs: '\x0f' } + ] as const + + for (const { term, enacs, smacs, rmacs } of acsStrategies) { + it(`draws a box after a normal-buffer replay under ${term}`, async () => { + const model = createRendererParityTerminal({ cols: 20, rows: 4 }) + const pane = new Terminal({ cols: 20, rows: 4, allowProposedApi: true }) + try { + await writeToTerminal(model.terminal, 'restored output') + // The application initialises its charsets once, before the gap. + await writeToTerminal(pane, enacs) + + await replayInto(pane, model, false) + await writeToTerminal(pane, `\r\n${smacs}lqqqk${rmacs}`) + + expect(row(pane, 1)).toBe('┌───┐') + } finally { + model.terminal.dispose() + pane.dispose() + } + }) + + it(`draws a box after an alt-screen replay under ${term}`, async () => { + const model = createRendererParityTerminal({ cols: 20, rows: 4 }) + const pane = new Terminal({ cols: 20, rows: 4, allowProposedApi: true }) + try { + await writeToTerminal(model.terminal, '\x1b[?1049hTUI FRAME') + await writeToTerminal(pane, `\x1b[?1049h${enacs}`) + + await replayInto(pane, model, true) + await writeToTerminal(pane, `\r\n${smacs}lqqqk${rmacs}`) + + expect(row(pane, 1)).toBe('┌───┐') + } finally { + model.terminal.dispose() + pane.dispose() + } + }) + } + + // The gap can also strand a shift-out mid-run. GL is the one piece of charset + // state the replay does ground, because the payload renders through it. + it('renders the restored frame as ASCII even if the gap stranded a shift-out', async () => { + const model = createRendererParityTerminal({ cols: 20, rows: 4 }) + const pane = new Terminal({ cols: 20, rows: 4, allowProposedApi: true }) + try { + await writeToTerminal(model.terminal, 'qqq plain text') + await writeToTerminal(pane, '\x1b(B\x1b)0\x0e') + + await replayInto(pane, model, false) + + expect(row(pane, 0)).toBe('qqq plain text') + } finally { + model.terminal.dispose() + pane.dispose() + } + }) +}) + +describe('real TUI shapes survive a replay', () => { + // A vim-like editor: alt screen, a scroll region reserving the status line, + // and its own saved cursor. All of it is re-declared by the model snapshot, + // so the pane must end up matching the model rather than the gap. + it('keeps an editor scroll region and status line after a replay', async () => { + const model = createRendererParityTerminal({ cols: 20, rows: 6 }) + const pane = new Terminal({ cols: 20, rows: 6, allowProposedApi: true }) + try { + await writeToTerminal(model.terminal, '\x1b[?1049h\x1b[1;5r\x1b[1;1Hline one') + await writeToTerminal(model.terminal, '\x1b[6;1H-- INSERT --') + // The gap left a different region and a stranded origin mode behind. + await writeToTerminal(pane, '\x1b[?1049h\x1b[2;3r\x1b[?6h') + + await replayInto(pane, model, true) + // Scroll INSIDE the restored region: reading the painted rows alone would + // pass even if `1;5r` never came back, so make the region do work. + await writeToTerminal(pane, '\x1b[5;1H\n\nscrolled') + + // Row 6 is outside the model's 1;5 region and must not have moved. + expect(row(pane, 5)).toBe('-- INSERT --') + expect(row(pane, 4)).toBe('scrolled') + } finally { + model.terminal.dispose() + pane.dispose() + } + }) + + // A pager filling the viewport. Deliberately a NORMAL-buffer snapshot with the + // pane already on normal, so no `?1049` is emitted at all: on the alt path the + // split branch's `?1049l` restores saved modes and would grant autowrap back + // for free, and the test would pass without the baseline doing anything. + it('keeps every column of a pager frame when the gap stranded autowrap off', async () => { + const model = createRendererParityTerminal({ cols: 10, rows: 4 }) + const pane = new Terminal({ cols: 10, rows: 4, allowProposedApi: true }) + try { + await writeToTerminal(model.terminal, 'ABCDEFGHIJKLMN') + await writeToTerminal(pane, '\x1b[?7l') + + await replayInto(pane, model, false) + + expect([row(pane, 0), row(pane, 1)]).toEqual(['ABCDEFGHIJ', 'KLMN']) + } finally { + model.terminal.dispose() + pane.dispose() + } + }) + + // Same reasoning for insert mode: a later in-place repaint by the app must + // overwrite, not shift. No buffer switch, so the baseline is the only source. + it('keeps a live repaint overwriting when the gap stranded insert mode', async () => { + const model = createRendererParityTerminal({ cols: 12, rows: 3 }) + const pane = new Terminal({ cols: 12, rows: 3, allowProposedApi: true }) + try { + await writeToTerminal(model.terminal, 'OLDTEXT') + await writeToTerminal(pane, '\x1b[4h') + + await replayInto(pane, model, false) + await writeToTerminal(pane, '\x1b[1;1HNEW') + + expect(row(pane, 0)).toBe('NEWTEXT') + } finally { + model.terminal.dispose() + pane.dispose() + } + }) + + // And reverse wraparound, which only manifests through a later backspace. + it('keeps a live backspace from chewing the row above after a replay', async () => { + const model = createRendererParityTerminal({ cols: 8, rows: 3 }) + const pane = new Terminal({ cols: 8, rows: 3, allowProposedApi: true }) + try { + await writeToTerminal(model.terminal, 'AAAAAAAABBBB') + await writeToTerminal(pane, '\x1b[?45h') + + await replayInto(pane, model, false) + await writeToTerminal(pane, '\b\b\b\b\bX') + + expect([row(pane, 0), row(pane, 1)]).toEqual(['AAAAAAAA', 'XBBB']) + } finally { + model.terminal.dispose() + pane.dispose() + } + }) + + // An agent that negotiated the kitty keyboard protocol keeps it: the pane is + // already on the buffer the snapshot describes, so nothing should switch. + it('keeps a live agent kitty negotiation when no buffer switch is needed', async () => { + const options = { + cols: 20, + rows: 4, + allowProposedApi: true, + vtExtensions: { kittyKeyboard: true } + } + const readFlags = (term: Terminal): unknown => { + const flags = (term as unknown as { _core: { coreService: { kittyKeyboard: unknown } } }) + ._core.coreService.kittyKeyboard + expect(flags).toBeDefined() + return flags + } + const model = createRendererParityTerminal({ cols: 20, rows: 4 }) + const pane = new Terminal(options) + const untouched = new Terminal(options) + try { + await writeToTerminal(model.terminal, 'shell output') + await writeToTerminal(pane, '\x1b[>1u') + await writeToTerminal(untouched, '\x1b[>1u') + + await replayInto(pane, model, false) + + expect(readFlags(pane)).toEqual(readFlags(untouched)) + } finally { + model.terminal.dispose() + pane.dispose() + untouched.dispose() + } + }) +}) + +describe('repeated gap and restore cycles', () => { + // Each restore must be idempotent: three gaps in a row should leave the pane + // exactly where one does, with no state accumulating across the prologues. + it('converges on the same frame after three successive restores', async () => { + const model = createRendererParityTerminal({ cols: 20, rows: 4 }) + const once = new Terminal({ cols: 20, rows: 4, allowProposedApi: true }) + const thrice = new Terminal({ cols: 20, rows: 4, allowProposedApi: true }) + // Attributes and a mode-sensitive operation, not just text: stranded bold, + // margins and autowrap are all invisible to a text-only comparison. + const describeFrame = async (terminal: Terminal): Promise => { + await writeToTerminal(terminal, '\x1b[3;1Hzz\bY') + return JSON.stringify({ + type: terminal.buffer.active.type, + rows: [0, 1, 2, 3].map((index) => rowWithAttributes(terminal, index)), + cursor: [terminal.buffer.active.cursorX, terminal.buffer.active.cursorY] + }) + } + try { + await writeToTerminal(model.terminal, '\x1b[?1049hframe body') + + await replayInto(once, model, false) + for (let cycle = 0; cycle < 3; cycle++) { + // A fresh gap strands new state before each restore. + await writeToTerminal(thrice, '\x1b[1m\x1b[2;3r\x1b[?7l\x1b]0;TRUNC') + // Only the first restore starts from the normal buffer. + await replayInto(thrice, model, cycle !== 0) + } + + expect(await describeFrame(thrice)).toBe(await describeFrame(once)) + } finally { + model.terminal.dispose() + once.dispose() + thrice.dispose() + } + }) +}) + +describe('content fidelity through a replay', () => { + // Wide glyphs, combining sequences and an exact-width wrap are where a + // mis-grounded autowrap or insert mode shows up as lost or shifted cells. + const payloads = [ + { label: 'CJK wide glyphs', body: '日本語テキスト' }, + { label: 'emoji with ZWJ', body: 'a👩‍💻b' }, + { label: 'exact-width wrap', body: 'ABCDEFGHIJKLMNOPQRST' }, + { label: 'combining marks', body: 'ééé' } + ] as const + + for (const { label, body } of payloads) { + it(`replays ${label} identically into a gap-dirtied pane`, async () => { + const model = createRendererParityTerminal({ cols: 20, rows: 4 }) + const clean = new Terminal({ cols: 20, rows: 4, allowProposedApi: true }) + const dirty = new Terminal({ cols: 20, rows: 4, allowProposedApi: true }) + try { + await writeToTerminal(model.terminal, body) + await replayInto(clean, model, false) + + await writeToTerminal(dirty, '\x1b[1m\x1b(0\x1b[2;3r\x1b[?7l\x1b[4h\x1b7') + await replayInto(dirty, model, false) + + expect([0, 1, 2, 3].map((index) => rowWithAttributes(dirty, index))).toEqual( + [0, 1, 2, 3].map((index) => rowWithAttributes(clean, index)) + ) + } finally { + model.terminal.dispose() + clean.dispose() + dirty.dispose() + } + }) + } +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts index d917b17799e..7ea53970814 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from 'vitest' import { Terminal } from '@xterm/headless' -import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles' +import { + ABORT_TRUNCATED_CONTROL_STRING, + buildSnapshotReplayPrologue, + RESET_AFTER_BYTE_GAP +} from '../../../../shared/terminal-mode-reset-profiles' +import { + buildParityMainBufferSnapshot, + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + visibleRowStyles, + writeToTerminal +} from '../../../../shared/terminal-restore-parity-fixture' import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, @@ -12,10 +24,22 @@ function writeTerminal(terminal: Terminal, data: string): Promise { return new Promise((resolve) => terminal.write(data, resolve)) } +const NORMAL_BUFFER_PROLOGUE_FROM_ALT = `${ABORT_TRUNCATED_CONTROL_STRING}${buildSnapshotReplayPrologue({ targetAlternateScreen: false, paneOnAlternateScreen: true })}` +const NORMAL_BUFFER_PROLOGUE = `${ABORT_TRUNCATED_CONTROL_STRING}${buildSnapshotReplayPrologue({ targetAlternateScreen: false, paneOnAlternateScreen: false })}` +const ALT_BUFFER_PROLOGUE = `${ABORT_TRUNCATED_CONTROL_STRING}${buildSnapshotReplayPrologue({ targetAlternateScreen: true, paneOnAlternateScreen: false })}` +// A pane already in alt screen is the production case for an alt snapshot, and +// it is NOT the same bytes: no switch, so none of `?1049h`'s side effects run. +const ALT_BUFFER_PROLOGUE_FROM_ALT = `${ABORT_TRUNCATED_CONTROL_STRING}${buildSnapshotReplayPrologue({ targetAlternateScreen: true, paneOnAlternateScreen: true })}` + +function readRows(terminal: Terminal, count: number): (string | undefined)[] { + return Array.from({ length: count }, (_, row) => + terminal.buffer.active.getLine(row)?.translateToString(true) + ) +} + describe('RESET_AFTER_BYTE_GAP', () => { // Real emulator rather than a mock: the guarantee is that a cell written after - // the gap reset carries none of the pen the gap stranded (STA-4042). Scoped to - // SGR — see RESET_AFTER_BYTE_GAP for why the wider grounding was dropped. + // the gap reset carries none of the pen the gap stranded (STA-4042). it('grounds the SGR pen so post-gap cells are not bold', async () => { const terminal = new Terminal({ cols: 40, rows: 2, allowProposedApi: true }) try { @@ -33,6 +57,559 @@ describe('RESET_AFTER_BYTE_GAP', () => { terminal.dispose() } }) + + // xterm dispatches OSC/DCS/APC with `success = code !== 0x18 && code !== 0x1a`, + // so a bare ESC would COMMIT whatever the gap truncated. Without the CAN this + // retitles the pane from a half-read OSC 0 (and writes the clipboard from a + // half-read OSC 52). + it('discards a control string the gap truncated instead of committing it', async () => { + const terminal = new Terminal({ cols: 40, rows: 2, allowProposedApi: true }) + let title = '' + terminal.onTitleChange((next) => { + title = next + }) + try { + await writeTerminal(terminal, '\x1b]0;real-title-TRUNCA') + await writeTerminal(terminal, `${RESET_AFTER_BYTE_GAP}after`) + + expect(title).toBe('') + // Still resynchronised: the bytes after the reset render as text. + expect(terminal.buffer.active.getLine(0)?.translateToString(true)).toBe('after') + } finally { + terminal.dispose() + } + }) +}) + +// The end-to-end invariant, built through the SAME composer production uses +// (buildParityMainBufferSnapshot mirrors HeadlessEmulator.getSnapshot: absolute +// cursor + DECSC epilogue, the alt frame split from normal scrollback, and the +// rehydrate prefix owning the single `?1049h`). Whatever the gap stranded, a +// dirty renderer must land on exactly the frame a clean one produces. The +// per-element tests below pin *why*; this pins what a user would notice. +describe('replay convergence with a production-composed snapshot', () => { + const strandedByTheGap: Record = { + 'bold pen': '\x1b[1m', + 'G0 line drawing': '\x1b(0', + 'scroll margins': '\x1b[2;3r', + 'origin mode': '\x1b[2;3r\x1b[?6h', + 'autowrap off': '\x1b[?7l', + 'insert mode': '\x1b[4h', + 'reverse wraparound': '\x1b[?45h', + 'saved-cursor register': '\x1b7', + 'truncated OSC': '\x1b]0;TRUNCATED', + 'all of them at once': + '\x1b[1m\x1b(0\x1b)0\x1b[2;3r\x1b[?6h\x1b[?7l\x1b[?45h\x1b[4h\x1b7\x1b]0;TRUNCATED' + } + + function describeFrame(terminal: Terminal): string { + return JSON.stringify({ + buffer: terminal.buffer.active.type, + rows: readRows(terminal, terminal.rows), + normal: readRows(terminal, terminal.rows).length > 0 ? normalBufferRowsTrimmed(terminal) : [], + styles: visibleRowStyles(terminal), + cursor: cursorPosition(terminal) + }) + } + + for (const alternateScreen of [true, false]) { + const branch = alternateScreen ? 'alt-screen' : 'normal-buffer' + it(`lands on the same ${branch} frame from any stranded state`, async () => { + const model = createRendererParityTerminal({ cols: 20, rows: 5 }) + try { + const enterAlt = alternateScreen ? '\x1b[?1049h' : '' + // The DECSC makes readSavedCursorRegister return a real register, so the + // snapshot carries the absolute-cursor epilogue (`\x1b[r`, `?6l`, CUP, + // `\x1b7`) — without it that whole branch of the payload never fires. + await writeToTerminal( + model.terminal, + `${enterAlt}\x1b[2;5H\x1b7\x1b[33mONE\r\nplain two\r\n\x1b[1mbold three` + ) + const snapshot = buildParityMainBufferSnapshot(model, 1) + expect(snapshot.alternateScreen).toBe(alternateScreen) + // Pin that this is the production shape: the alt case must take the + // split branch, and the payload must carry the cursor epilogue. + expect(snapshot.scrollbackAnsi === undefined).toBe(!alternateScreen) + expect(snapshot.data).toContain('\x1b7') + + const replay = async (terminal: Terminal): Promise => { + for (const write of buildMainModelSnapshotReplayWrites(snapshot, { + paneOnAlternateScreen: alternateScreen + })) { + await writeTerminal(terminal, write) + } + } + + const clean = createRendererParityTerminal({ cols: 20, rows: 5 }) + let expected: string + try { + await writeToTerminal(clean.terminal, enterAlt) + await replay(clean.terminal) + expected = describeFrame(clean.terminal) + } finally { + clean.terminal.dispose() + } + + for (const [label, stranded] of Object.entries(strandedByTheGap)) { + const dirty = createRendererParityTerminal({ cols: 20, rows: 5 }) + try { + await writeToTerminal(dirty.terminal, enterAlt) + await writeToTerminal(dirty.terminal, stranded) + await replay(dirty.terminal) + expect(`${label}: ${describeFrame(dirty.terminal)}`).toBe(`${label}: ${expected}`) + } finally { + dirty.terminal.dispose() + } + } + } finally { + model.terminal.dispose() + } + }) + } +}) + +// Each case strands one piece of carried state, then replays a payload through +// the real prologue. SerializeAddon diffs against the default cell and emits no +// charset at all, so anything the gap left set silently rewrites the frame. +describe('replay baseline grounding', () => { + const strandedState: Record = { + 'bold pen': '\x1b[1m', + 'DEC line-drawing charset': '\x1b(0', + 'scroll region': '\x1b[2;3r', + 'origin mode': '\x1b[2;3r\x1b[?6h' + } + + for (const [label, stranded] of Object.entries(strandedState)) { + it(`replays a normal-buffer snapshot unchanged despite a stranded ${label}`, async () => { + const terminal = new Terminal({ cols: 10, rows: 6, allowProposedApi: true }) + try { + await writeTerminal(terminal, stranded) + await writeTerminal(terminal, `${NORMAL_BUFFER_PROLOGUE}qqq\r\nB\r\nC\r\nD\r\nE\r\nF`) + + expect(readRows(terminal, 6)).toEqual(['qqq', 'B', 'C', 'D', 'E', 'F']) + expect(terminal.buffer.active.getLine(0)?.getCell(0)?.isBold()).toBe(0) + } finally { + terminal.dispose() + } + }) + } + + // The field path, driven through the REAL builder output rather than a + // hand-written prologue: an alt-screen agent whose snapshot splits normal + // scrollback from the alt frame. This is the branch STA-4042 was reported on. + it('replays a split alt-screen snapshot clean when the TUI entered alt with a pen set', async () => { + const terminal = new Terminal({ cols: 24, rows: 4, allowProposedApi: true }) + try { + await writeTerminal(terminal, '\x1b[1m\x1b(0') + // ?1049h saves that pen and charset into the register ?1049l restores from. + await writeTerminal(terminal, '\x1b[?1049hTUI FRAME') + + for (const write of buildMainModelSnapshotReplayWrites( + { data: 'alt-frame', alternateScreen: true, scrollbackAnsi: 'qqq scrollback' }, + { paneOnAlternateScreen: true } + )) { + await writeTerminal(terminal, write) + } + + // The alt frame is what shows; the rebuilt normal history must be clean too. + expect(terminal.buffer.active.getLine(0)?.translateToString(true)).toBe('alt-frame') + expect(terminal.buffer.active.getLine(0)?.getCell(0)?.isBold()).toBe(0) + expect(terminal.buffer.normal.getLine(0)?.translateToString(true)).toBe('qqq scrollback') + expect(terminal.buffer.normal.getLine(0)?.getCell(0)?.isBold()).toBe(0) + } finally { + terminal.dispose() + } + }) + + // Ordering regression. xterm answers `?1049l` with restoreCursor(), which + // reloads the pen, every G-set designation, GL, origin mode and wraparound + // from the register `?1049h` saved. A TUI that was bold and line-drawing when + // it entered the alt screen therefore gets all of that RESTORED on the way + // out — so grounding emitted ahead of the buffer switch is silently undone, + // and the exact bold-bleed this whole path exists to prevent comes back. + it('grounds after the buffer switch, not before, so ?1049l cannot restore the stale pen', async () => { + const terminal = new Terminal({ cols: 20, rows: 3, allowProposedApi: true }) + try { + await writeTerminal(terminal, '\x1b[1m\x1b(0\x1b[?7l') + // ?1049h saves that pen, charset and wraparound into the restore register. + await writeTerminal(terminal, '\x1b[?1049hTUI FRAME') + await writeTerminal(terminal, `${NORMAL_BUFFER_PROLOGUE_FROM_ALT}qqq restored`) + + const line = terminal.buffer.active.getLine(0) + expect(line?.translateToString(true)).toBe('qqq restored') + expect(line?.getCell(0)?.isBold()).toBe(0) + } finally { + terminal.dispose() + } + }) + + // `?1049l` runs restoreCursor() unconditionally, which rewrites the pen, all + // four G-sets, GL, origin, wraparound and the cursor from the register saved + // at `?1049h`. Poison every one of those at once and prove the prologue after + // the switch neutralises the lot — this is the guard on adding `?1049l` at all. + it('neutralises every field ?1049l restores, even with the saved register poisoned', async () => { + const terminal = new Terminal({ cols: 20, rows: 6, allowProposedApi: true }) + try { + await writeTerminal(terminal, '\x1b[1;31;44m') // pen: bold + fg + bg + await writeTerminal(terminal, '\x1b(0\x1b)0\x1b*0\x1b+0') // G0..G3 -> line drawing + await writeTerminal(terminal, '\x0e') // LS1: GL -> G1 + await writeTerminal(terminal, '\x1b[2;5r\x1b[?6h\x1b[?7l\x1b[4h') // margins, origin, no wrap, insert + await writeTerminal(terminal, '\x1b[3;7H') // non-home cursor + await writeTerminal(terminal, '\x1b[?1049hTUI FRAME') // saveCursor() captures all of it + + // 24 columns of `q` into a 20-column grid: only autowrap on, GL=G0 and + // G0=ASCII produce this, and only a homed cursor starts it on row 0. + await writeTerminal(terminal, `${NORMAL_BUFFER_PROLOGUE_FROM_ALT}${'q'.repeat(24)}`) + + expect(terminal.buffer.active.type).toBe('normal') + expect(readRows(terminal, 2)).toEqual(['q'.repeat(20), 'qqqq']) + const cell = terminal.buffer.active.getLine(0)?.getCell(0) + expect(cell?.isBold()).toBe(0) + expect(cell?.isFgDefault()).toBe(true) + expect(cell?.isBgDefault()).toBe(true) + } finally { + terminal.dispose() + } + }) + + // Panes run with vtExtensions.kittyKeyboard on, and xterm swaps the kitty + // flag registers on the ?1049 transition. Without the ?1049l the renderer + // keeps the agent's negotiated flags after the agent exited, so the pane + // sends CSI-u key encodings to a plain shell that never asked for them. + // Replaying the snapshot must land on the same state the model already has. + it('converges on the model kitty keyboard state when the gap ate the alt-screen exit', async () => { + const options = { + cols: 20, + rows: 4, + allowProposedApi: true, + vtExtensions: { kittyKeyboard: true } + } + const readKittyFlags = (term: Terminal): unknown => { + const flags = (term as unknown as { _core: { coreService: { kittyKeyboard: unknown } } }) + ._core.coreService.kittyKeyboard + // Without this the assertion passes vacuously if xterm renames the field. + expect(flags).toBeDefined() + return flags + } + + // The model ingested every byte: agent entered alt, negotiated kitty, exited. + const model = new Terminal(options) + // The renderer missed the exit and is still on alt with the agent's flags. + const renderer = new Terminal(options) + try { + await writeTerminal(model, '\x1b[?1049h\x1b[>1u') + await writeTerminal(model, '\x1b[?1049l') + + await writeTerminal(renderer, '\x1b[?1049h\x1b[>1u') + for (const write of buildMainModelSnapshotReplayWrites( + { data: 'shell prompt $' }, + { paneOnAlternateScreen: true } + )) { + await writeTerminal(renderer, write) + } + + expect(renderer.buffer.active.type).toBe(model.buffer.active.type) + expect(readKittyFlags(renderer)).toEqual(readKittyFlags(model)) + } finally { + model.dispose() + renderer.dispose() + } + }) + + // Delayed arm of the same defect. `?1049h` banks the CURRENT pen and charsets + // into the register the TUI's eventual `?1049l` restores from, so grounding + // only after the switch leaves the gap's state saved for later: the repaint + // looks clean and the shell prompt comes back bold whenever the agent exits. + it('grounds before the switch too, so the alt-exit later cannot restore the gap state', async () => { + const terminal = new Terminal({ cols: 20, rows: 3, allowProposedApi: true }) + try { + await writeTerminal(terminal, '\x1b[1m\x1b(0\x1b[?7l') + await writeTerminal(terminal, `${ALT_BUFFER_PROLOGUE}alt frame`) + // The agent exits the alt screen some time after the repaint. + await writeTerminal(terminal, '\x1b[?1049l') + await writeTerminal(terminal, 'qqq shell prompt') + + const line = terminal.buffer.active.getLine(0) + expect(line?.translateToString(true)).toBe('qqq shell prompt') + expect(line?.getCell(0)?.isBold()).toBe(0) + } finally { + terminal.dispose() + } + }) + + // `?1049` is NOT a no-op when the pane is already on the target buffer: xterm + // skips only the buffer swap and still swaps the kitty flag registers. An + // agent that negotiated kitty on the normal screen and never entered alt + // would lose its flags (1 -> 0, Option chords dead) on every normal-buffer + // restore, so the prologue must not switch when there is nothing to switch. + it('leaves kitty flags alone when the pane is already on the target buffer', async () => { + const options = { + cols: 20, + rows: 3, + allowProposedApi: true, + vtExtensions: { kittyKeyboard: true } + } + const readKittyFlags = (term: Terminal): unknown => { + const flags = (term as unknown as { _core: { coreService: { kittyKeyboard: unknown } } }) + ._core.coreService.kittyKeyboard + // Without this the assertion passes vacuously if xterm renames the field. + expect(flags).toBeDefined() + return flags + } + + const model = new Terminal(options) + const renderer = new Terminal(options) + try { + await writeTerminal(model, '\x1b[>1u') + await writeTerminal(renderer, '\x1b[>1u') + + const writes = buildMainModelSnapshotReplayWrites( + { data: 'restored' }, + { paneOnAlternateScreen: false } + ) + for (const write of writes) { + await writeTerminal(renderer, write) + } + + expect(writes[0]).not.toContain('\x1b[?1049') + expect(readKittyFlags(renderer)).toEqual(readKittyFlags(model)) + } finally { + model.dispose() + renderer.dispose() + } + }) + + // Reverse wraparound is the same shape as insert and autowrap: the model + // re-emits `?45h` only when IT has the mode on, so a stranded ON survives the + // repaint and the live app's next backspace at column 0 eats into the row + // above instead of staying put. + it('leaves reverse wraparound off so a later backspace cannot chew the row above', async () => { + const terminal = new Terminal({ cols: 8, rows: 3, allowProposedApi: true }) + try { + await writeTerminal(terminal, '\x1b[?45h') + // 12 columns into 8 makes row 1 a SOFT wrap of row 0 — the only kind + // reverse wraparound reverses. + await writeTerminal(terminal, `${NORMAL_BUFFER_PROLOGUE}AAAAAAAABBBB`) + await writeTerminal(terminal, '\b\b\b\b\bX') + + expect(readRows(terminal, 2)).toEqual(['AAAAAAAA', 'XBBB']) + } finally { + terminal.dispose() + } + }) + + // The alt branch cannot make the same promise, and the earlier version of this + // test only appeared to because it used synthetic payload data. Production alt + // payloads carry their OWN `\x1b[0m\x1b[?1049h` (buildRehydrateSequences / + // frameRestoreAnsi), so the flags of an agent that negotiated kitty on the alt + // screen are still pushed into the main register by the payload. The prologue's + // contribution is what is testable here: it adds no switch of its own. + it('adds no buffer switch of its own when the pane is already on the alt screen', async () => { + const options = { + cols: 20, + rows: 3, + allowProposedApi: true, + vtExtensions: { kittyKeyboard: true } + } + const readKittyFlags = (term: Terminal): unknown => { + const flags = (term as unknown as { _core: { coreService: { kittyKeyboard: unknown } } }) + ._core.coreService.kittyKeyboard + // Without this the assertion passes vacuously if xterm renames the field. + expect(flags).toBeDefined() + return flags + } + + const renderer = new Terminal(options) + try { + await writeTerminal(renderer, '\x1b[?1049h\x1b[>1u') + const before = JSON.stringify(readKittyFlags(renderer)) + + const writes = buildMainModelSnapshotReplayWrites( + { data: 'alt-frame', alternateScreen: true }, + { paneOnAlternateScreen: true } + ) + expect(writes[0]).not.toContain('\x1b[?1049') + for (const write of writes) { + await writeTerminal(renderer, write) + } + // Nothing in the prologue moved them; only a payload `?1049h` could. + expect(JSON.stringify(readKittyFlags(renderer))).toBe(before) + } finally { + renderer.dispose() + } + }) + + // Margins are per buffer and the switch does not carry them across, so an + // alt-target replay from a normal pane must ground the buffer it leaves too — + // otherwise the stale region resurfaces the moment the TUI exits to normal. + it('grounds the source buffer margins when switching to the alt screen', async () => { + const terminal = new Terminal({ cols: 20, rows: 6, allowProposedApi: true }) + try { + await writeTerminal(terminal, '\x1b[2;3r') + for (const write of buildMainModelSnapshotReplayWrites( + { data: 'alt-frame', alternateScreen: true }, + { paneOnAlternateScreen: false } + )) { + await writeTerminal(terminal, write) + } + await writeTerminal(terminal, '\x1b[?1049l') + await writeTerminal(terminal, 'AAAA\r\nBBBB\r\nCCCC\r\nDDDD\r\nEEEE\r\nFFFF') + + expect(readRows(terminal, 6)).toEqual(['AAAA', 'BBBB', 'CCCC', 'DDDD', 'EEEE', 'FFFF']) + } finally { + terminal.dispose() + } + }) + + // The inverse of what an earlier revision asserted. Grounding G1-G3 looked + // right against a stranded designation, but the payload never leaves G0 so it + // cannot affect the frame, and `enacs=\E(B\E)0` (screen/tmux/vt100 terminfo) + // designates G1 once at init and then uses bare SO/SI — grounding it renders a + // live app's box drawing as letters. The model never re-asserts charsets, so + // there is nothing to complete the reset. Leave the app's G-sets alone. + it('leaves an app-designated G1 intact so its later shift-out still draws boxes', async () => { + const terminal = new Terminal({ cols: 20, rows: 3, allowProposedApi: true }) + try { + // enacs: G0 = ASCII, G1 = DEC line drawing. Runs once, at init. + await writeTerminal(terminal, '\x1b(B\x1b)0') + await writeTerminal(terminal, `${NORMAL_BUFFER_PROLOGUE}restored`) + // smacs / box / rmacs, the way the app draws for the rest of the session. + await writeTerminal(terminal, '\r\n\x0elqqqk\x0f') + + expect(terminal.buffer.active.getLine(1)?.translateToString(true)).toBe('┌───┐') + } finally { + terminal.dispose() + } + }) + + // The baseline is reachable around if the saved-cursor register still holds + // the gap's state: the live TUI's next `ESC 8` restores that pen and charset + // straight over the repaint. The prologue's trailing DECSC closes that door. + it('grounds the saved-cursor register so a later ESC 8 cannot restore the stale pen', async () => { + const terminal = new Terminal({ cols: 20, rows: 3, allowProposedApi: true }) + try { + // The gap strands a DECSC taken while bold + line-drawing were set. + await writeTerminal(terminal, '\x1b[1m\x1b(0\x1b7') + await writeTerminal(terminal, `${NORMAL_BUFFER_PROLOGUE}restored`) + // The live TUI restores its cursor, then draws. + await writeTerminal(terminal, '\x1b8qqq') + + const line = terminal.buffer.active.getLine(0) + expect(line?.translateToString(true)).toBe('qqqtored') + expect(line?.getCell(0)?.isBold()).toBe(0) + } finally { + terminal.dispose() + } + }) + + // A serialized row exactly `cols` wide relies on autowrap to continue onto the + // next row. With DECAWM stranded off, every character past the margin + // overwrites the last cell and the restored history silently loses text. + it('replays a row wider than the grid instead of dropping it at the margin', async () => { + const terminal = new Terminal({ cols: 10, rows: 4, allowProposedApi: true }) + try { + await writeTerminal(terminal, '\x1b[?7l') + await writeTerminal(terminal, `${NORMAL_BUFFER_PROLOGUE}ABCDEFGHIJKLMNO`) + + expect(readRows(terminal, 2)).toEqual(['ABCDEFGHIJ', 'KLMNO']) + } finally { + terminal.dispose() + } + }) + + // Origin mode is invisible during the replay itself (the baseline resets the + // margins, so margin-relative and absolute addressing coincide), but nothing + // in the snapshot re-emits `ESC[?6l`. Left stranded, the first scroll region + // the live TUI sets makes all of its cursor addressing margin-relative. + it('leaves origin mode off so later cursor addressing stays absolute', async () => { + const terminal = new Terminal({ cols: 10, rows: 6, allowProposedApi: true }) + try { + // Alt branch: `?1049h` on an already-alt pane is a no-op, so unlike the + // normal branch's `?1049l` it restores nothing on its own. + await writeTerminal(terminal, '\x1b[?1049h') + await writeTerminal(terminal, '\x1b[2;3r\x1b[?6h') + await writeTerminal(terminal, `${ALT_BUFFER_PROLOGUE_FROM_ALT}restored`) + // The live TUI re-establishes its own region and homes the cursor. + await writeTerminal(terminal, '\x1b[4;6r\x1b[1;1HTOP') + + expect(terminal.buffer.active.getLine(0)?.translateToString(true)).toBe('TOPtored') + } finally { + terminal.dispose() + } + }) + + // Insert mode survives the replay itself (the frame is painted onto a cleared + // screen), but the model only re-emits `ESC[4h` when IT has insert mode on — + // so a stranded-on renderer keeps it, and every later repaint by the live TUI + // shifts existing cells right instead of overwriting them. + it('leaves insert mode off so later repaints overwrite rather than shift', async () => { + const terminal = new Terminal({ cols: 10, rows: 4, allowProposedApi: true }) + try { + await writeTerminal(terminal, '\x1b[4h') + await writeTerminal(terminal, `${NORMAL_BUFFER_PROLOGUE}OLDTEXT`) + // A live in-place repaint after the replay, exactly as a TUI would send it. + await writeTerminal(terminal, '\x1b[1;1HNEW') + + expect(readRows(terminal, 1)).toEqual(['NEWTEXT']) + } finally { + terminal.dispose() + } + }) + + // The alt branch needs its own coverage: leaving the alt screen restores + // saved modes, so `?1049l` on the normal branch happens to clear autowrap and + // origin mode for free. `?1049h` does not, and on a pane already in alt screen + // it is a no-op — nothing but the explicit baseline grounds these. + for (const [label, stranded, replayed, expected] of [ + ['autowrap off', '\x1b[?7l', 'ABCDEFGHIJKLMNO', ['ABCDEFGHIJ', 'KLMNO']], + ['origin mode', '\x1b[2;3r\x1b[?6h', 'A\r\nB\r\nC', ['A', 'B', 'C']], + ['DEC line-drawing charset', '\x1b(0', 'qqq', ['qqq', '']] + ] as const) { + it(`grounds a stranded ${label} on the alt-screen branch`, async () => { + const terminal = new Terminal({ cols: 10, rows: 4, allowProposedApi: true }) + try { + await writeTerminal(terminal, '\x1b[?1049h') + await writeTerminal(terminal, stranded) + await writeTerminal(terminal, `${ALT_BUFFER_PROLOGUE_FROM_ALT}${replayed}`) + + expect(readRows(terminal, expected.length)).toEqual([...expected]) + } finally { + terminal.dispose() + } + }) + } + + // Margins are per buffer and `?1049h` only clears them when it actually + // switches, so a pane already in alt screen keeps the stale region. + it('grounds the alt buffer margins even when already on the alt screen', async () => { + const terminal = new Terminal({ cols: 10, rows: 6, allowProposedApi: true }) + try { + await writeTerminal(terminal, '\x1b[?1049h') + await writeTerminal(terminal, '\x1b[2;3r') + await writeTerminal(terminal, `${ALT_BUFFER_PROLOGUE_FROM_ALT}A\r\nB\r\nC\r\nD\r\nE\r\nF`) + + expect(readRows(terminal, 6)).toEqual(['A', 'B', 'C', 'D', 'E', 'F']) + } finally { + terminal.dispose() + } + }) + + // The gap can eat the TUI's own `?1049l`, leaving the renderer on the alt + // screen while the model already moved back to normal. Painting the restored + // history into the alt buffer looks right but leaves scrollback empty and the + // content vanishes on the next buffer switch. + it('returns to the normal buffer before replaying a normal-buffer snapshot', async () => { + const terminal = new Terminal({ cols: 20, rows: 3, allowProposedApi: true }) + try { + await writeTerminal(terminal, '\x1b[?1049hSTALE TUI FRAME') + await writeTerminal(terminal, `${NORMAL_BUFFER_PROLOGUE_FROM_ALT}shell prompt $`) + + expect(terminal.buffer.active.type).toBe('normal') + expect(terminal.buffer.normal.getLine(0)?.translateToString(true)).toBe('shell prompt $') + } finally { + terminal.dispose() + } + }) }) describe('hasPositiveTerminalDimensions', () => { @@ -65,10 +642,9 @@ describe('resolvePositiveTerminalDimensions', () => { describe('buildMainModelSnapshotReplayWrites', () => { it('clears normal buffer + scrollback before a normal-buffer snapshot', () => { - expect(buildMainModelSnapshotReplayWrites({ data: 'shell-output' })).toEqual([ - `${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, - 'shell-output' - ]) + expect( + buildMainModelSnapshotReplayWrites({ data: 'shell-output' }, { paneOnAlternateScreen: false }) + ).toEqual([NORMAL_BUFFER_PROLOGUE, 'shell-output']) }) // Why: main strips the ?1049h marker when splitting scrollbackAnsi from an @@ -77,23 +653,25 @@ describe('buildMainModelSnapshotReplayWrites', () => { // the normal buffer. it('rebuilds normal buffer then paints a clean alt frame for alt-screen snapshots', () => { expect( - buildMainModelSnapshotReplayWrites({ - data: 'alt-frame', - alternateScreen: true, - scrollbackAnsi: 'normal-history' - }) + buildMainModelSnapshotReplayWrites( + { data: 'alt-frame', alternateScreen: true, scrollbackAnsi: 'normal-history' }, + { paneOnAlternateScreen: false } + ) ).toEqual([ - `${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, + NORMAL_BUFFER_PROLOGUE, 'normal-history', - `${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, + buildSnapshotReplayPrologue({ targetAlternateScreen: true, paneOnAlternateScreen: false }), 'alt-frame' ]) }) it('enters a cleared alt screen when no split scrollback is available', () => { expect( - buildMainModelSnapshotReplayWrites({ data: 'alt-frame', alternateScreen: true }) - ).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'alt-frame']) + buildMainModelSnapshotReplayWrites( + { data: 'alt-frame', alternateScreen: true }, + { paneOnAlternateScreen: false } + ) + ).toEqual([ALT_BUFFER_PROLOGUE, 'alt-frame']) }) }) @@ -127,7 +705,10 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { try { const skipAltFrame = shouldSkipAltFrameForWidthMismatch(12, undefined) - for (const chunk of buildMainModelSnapshotReplayWrites(snapshot, { skipAltFrame })) { + for (const chunk of buildMainModelSnapshotReplayWrites(snapshot, { + skipAltFrame, + paneOnAlternateScreen: false + })) { await writeTerminal(terminal, chunk) } @@ -152,7 +733,10 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { } try { - for (const chunk of buildMainModelSnapshotReplayWrites(snapshot, { skipAltFrame: true })) { + for (const chunk of buildMainModelSnapshotReplayWrites(snapshot, { + skipAltFrame: true, + paneOnAlternateScreen: false + })) { await writeTerminal(terminal, chunk) } terminal.resize(4, 5) @@ -174,12 +758,12 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { alternateScreen: true, scrollbackAnsi: 'normal-history' }, - { skipAltFrame: true } + { skipAltFrame: true, paneOnAlternateScreen: false } ) ).toEqual([ - `${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, + NORMAL_BUFFER_PROLOGUE, 'normal-history', - `${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, + buildSnapshotReplayPrologue({ targetAlternateScreen: true, paneOnAlternateScreen: false }), 'complete-live-state' ]) }) @@ -194,24 +778,27 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { frameRestoreAnsi: 'complete-live-state', alternateScreen: true }, - { skipAltFrame: true } + { skipAltFrame: true, paneOnAlternateScreen: false } ) - ).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'complete-live-state']) + ).toEqual([ALT_BUFFER_PROLOGUE, 'complete-live-state']) }) it('keeps composed data when an older producer omits the mode boundary', () => { expect( buildMainModelSnapshotReplayWrites( { data: 'legacy-modes-and-frame', alternateScreen: true }, - { skipAltFrame: true } + { skipAltFrame: true, paneOnAlternateScreen: false } ) - ).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'legacy-modes-and-frame']) + ).toEqual([ALT_BUFFER_PROLOGUE, 'legacy-modes-and-frame']) }) it('never drops a normal-buffer snapshot, whose rows reflow correctly', () => { expect( - buildMainModelSnapshotReplayWrites({ data: 'shell-output' }, { skipAltFrame: true }) - ).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, 'shell-output']) + buildMainModelSnapshotReplayWrites( + { data: 'shell-output' }, + { skipAltFrame: true, paneOnAlternateScreen: false } + ) + ).toEqual([NORMAL_BUFFER_PROLOGUE, 'shell-output']) }) // STA-4042: a replay only runs because renderer-bound bytes were dropped, so @@ -219,18 +806,42 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { // BEFORE replaying content, or the whole restored buffer inherits it — the // "regular text renders bold" field report. it('clears the SGR pen before any replayed content in every branch', () => { + // Each built for a pane that genuinely has to switch, so there is a switch + // to bracket; when the pane is already on target no switch is emitted and + // there is nothing for restoreCursor to undo. const branches = [ - buildMainModelSnapshotReplayWrites({ data: 'normal-buffer' }), - buildMainModelSnapshotReplayWrites({ - data: 'alt-frame', - alternateScreen: true, - scrollbackAnsi: 'scrollback' - }), - buildMainModelSnapshotReplayWrites({ data: 'alt-frame', alternateScreen: true }) + buildMainModelSnapshotReplayWrites( + { data: 'normal-buffer' }, + { paneOnAlternateScreen: true } + ), + buildMainModelSnapshotReplayWrites( + { data: 'alt-frame', alternateScreen: true, scrollbackAnsi: 'scrollback' }, + { paneOnAlternateScreen: true } + ), + buildMainModelSnapshotReplayWrites( + { data: 'alt-frame', alternateScreen: true }, + { paneOnAlternateScreen: false } + ) ] for (const writes of branches) { - expect(writes[0].startsWith(RESET_AFTER_BYTE_GAP)).toBe(true) - // Nothing may be replayed ahead of the first reset. + // CAN leads, so a truncated control string is aborted rather than + // committed by the first ESC that follows. + expect(writes[0].startsWith('\x18')).toBe(true) + // Literal bytes, not the imported constant: asserting the constant against + // itself would pass for any value it happened to hold. + const prologue = writes[0] + expect(prologue).toContain('\x1b[0m') // SGR + expect(prologue).toContain('\x0f\x1b(B') // GL -> G0, G0 -> ASCII + expect(prologue).toContain('\x1b[r') // margins + expect(prologue.endsWith('\x1b7')).toBe(true) // saved-cursor floor, last + // The baseline brackets the buffer switch: once before, so `?1049h` banks + // grounded state rather than the gap's, and once after, so `?1049l`'s + // restore cannot reapply the old register over it. + const switchAt = prologue.indexOf('\x1b[?1049') + expect(switchAt).toBeGreaterThanOrEqual(0) + expect(prologue.indexOf('\x1b[0m')).toBeLessThan(switchAt) + expect(prologue.lastIndexOf('\x1b[0m')).toBeGreaterThan(switchAt) + // Nothing may be replayed ahead of the first prologue. expect(writes.indexOf('scrollback')).not.toBe(0) } }) diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts index 81fa4b6b525..fd5da6ae20b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts @@ -1,6 +1,15 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' import { readProposedPaneFitDimensions } from '@/lib/pane-manager/pane-fit' -import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles' +import { + ABORT_TRUNCATED_CONTROL_STRING, + buildSnapshotReplayPrologue +} from '../../../../shared/terminal-mode-reset-profiles' + +// Once only: CAN must precede the first ESC of the replay, but the split branch +// builds two prologues and later writes follow well-formed payloads. +function abortGapBeforeFirstWrite(writes: string[]): string[] { + return [`${ABORT_TRUNCATED_CONTROL_STRING}${writes[0]}`, ...writes.slice(1)] +} /** * Shared guards and write choreography for painting a main-model snapshot into @@ -77,16 +86,26 @@ export function buildMainModelSnapshotReplayWrites( alternateScreen?: boolean scrollbackAnsi?: string }, - options: { skipAltFrame?: boolean } = {} + options: { skipAltFrame?: boolean; paneOnAlternateScreen: boolean } ): string[] { + const { paneOnAlternateScreen } = options + const normalPrologue = buildSnapshotReplayPrologue({ + targetAlternateScreen: false, + paneOnAlternateScreen + }) + // The alt payload always follows a hop through the normal buffer in the split + // branch, so its own switch is judged from there, not from where we started. + const altPrologue = (fromAlternateScreen: boolean): string => + buildSnapshotReplayPrologue({ + targetAlternateScreen: true, + paneOnAlternateScreen: fromAlternateScreen + }) if (!snapshot.alternateScreen) { - // Why: \x1b[3J wipes xterm scrollback; safe here because a normal-buffer - // snapshot carries its own history in data (mirrors pty-transport.ts). - // Why the leading SGR reset: a replay only happens because renderer-bound - // bytes were dropped, so the pen the drop interrupted is unknown — without - // clearing it the whole replayed buffer inherits it (STA-4042). The - // alt-screen branches below already reset; this one did not. - return [`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, snapshot.data] + // Why the switch can be needed here: the gap can eat the TUI's own exit + // sequence, leaving the renderer on alt while the model moved to normal — + // the restored history would paint into the alt buffer, looking right while + // scrollback stays empty (STA-4042). + return abortGapBeforeFirstWrite([normalPrologue, snapshot.data]) } // Older snapshot producers do not expose the mode/frame boundary. Keep their // composed data rather than dropping terminal modes together with the frame. @@ -95,20 +114,17 @@ export function buildMainModelSnapshotReplayWrites( ? [snapshot.frameRestoreAnsi] : [snapshot.data] if (snapshot.scrollbackAnsi !== undefined) { - // Why: main serializes normal + alt buffers separately; rebuild normal - // while active, then return to a clean alt frame. - // Why the reset moved to the front too: scrollbackAnsi was replayed BEFORE - // the existing \x1b[0m, so the normal-buffer history inherited the stale pen - // even though the alt frame after it did not (STA-4042). - return [ - `${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, + // Why a prologue per payload: main serializes the buffers separately and + // each is diffed against the baseline. scrollbackAnsi used to replay before + // any reset at all, so the history inherited the stale state (STA-4042). + return abortGapBeforeFirstWrite([ + normalPrologue, snapshot.scrollbackAnsi, - `${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, + altPrologue(false), ...altFrame - ] + ]) } - // Why: the snapshot's ?1049h no-ops when already on alt screen and skips - // blank cells; clear the alt buffer so the pre-hide frame can't bleed - // through blank cells (spares normal-buffer scrollback). - return [`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, ...altFrame] + // Why the prologue clears: `?1049h` does not clear the alt buffer, so the + // pre-hide frame would bleed through the snapshot's blank cells. + return abortGapBeforeFirstWrite([altPrologue(paneOnAlternateScreen), ...altFrame]) } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 1c452f21bd7..ea8d60402ab 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -89,6 +89,10 @@ import { copyTerminalSelection } from './terminal-selection-copy' import { parseOsc7 } from './parse-osc7' import { guardParserHandler } from './terminal-parser-handler-guard' import { resolveTerminalJisYenInput } from './terminal-jis-yen-input' +import { + isNonLatinControlChordKeyup, + resolveNonLatinControlChordInput +} from './terminal-non-latin-control-chord' import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' import { installTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state' import { @@ -986,6 +990,7 @@ export function useTerminalPaneLifecycle({ // Why: let host-handled keys bypass xterm's kitty CSI-u encoder — with kittyKeyboard on it preventDefaults Cmd+C and blocks Chromium's native copy. See xterm-bypass-policy.ts. let pendingTerminalInterruptKeyup = false + let claimedNonLatinControlChordCode: string | null = null const pendingTerminalImeCandidateKeyReleases = createTerminalImePendingCandidateKeyReleases() const isMac = navigator.userAgent.includes('Mac') @@ -1083,6 +1088,22 @@ export function useTerminalPaneLifecycle({ observeLinuxCandidateEvent() return false } + // Why here: after the Ctrl+C interrupt arm, which owns its own ETX and kitty reset. + // This covers the other 25 letters, whose only failure is the kitty encoder reading + // the layout glyph out of `key`. Sending the C0 byte reproduces what the OS control + // table produces for that physical key on any layout. + if (isNonLatinControlChordKeyup(e, claimedNonLatinControlChordCode)) { + claimedNonLatinControlChordCode = null + observeLinuxCandidateEvent() + return false + } + const nonLatinControlChord = resolveNonLatinControlChordInput(e) + if (nonLatinControlChord) { + claimedNonLatinControlChordCode = e.code + pane.terminal.input(nonLatinControlChord) + observeLinuxCandidateEvent() + return false + } if (shouldSuppressTerminalModifierKeyboardEvent(e)) { // Why: stale Kitty keyboard reporting can encode standalone modifier presses before Ctrl+C reaches the interrupt handler. observeLinuxCandidateEvent() diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts index 4223ccf13f8..bf7e6c75bee 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' +import { _setLayoutMapForTests } from '../../lib/keyboard-layout/layout-base-character' import { shouldHandleTerminalInterruptKeyboardEvent, shouldSuppressTerminalInterruptKeyup, @@ -21,6 +22,9 @@ function event(overrides: Partial): XtermBypassEvent { } } +// The layout map is module-level cache; leaving one set would leak into later cases. +afterEach(() => _setLayoutMapForTests(null)) + describe('shouldHandleTerminalInterruptKeyboardEvent', () => { it('exports the ETX byte used for terminal interrupts', () => { expect(TERMINAL_INTERRUPT_INPUT).toBe('\x03') @@ -103,6 +107,99 @@ describe('shouldHandleTerminalInterruptKeyboardEvent', () => { ).toBe(false) }) + // #14460: with a non-Latin input source the OS reports the layout's own glyph for `key` + // (Hangul jamo on Korean 2-Set, Cyrillic es on Russian), while `code` stays KeyC. A + // non-Latin script cannot express a control chord in `key`, so `code` is the only signal + // that survives — and without this, Ctrl+C misses the ETX path and gets CSI-u encoded + // instead, leaving a TUI running. + it.each([ + ['Korean 2-Set', 'ㅊ'], + ['Russian', 'с'], + ['Greek', 'ψ'], + ['Hangul syllable', '차'] + ])('handles Ctrl+C when %s reports a non-Latin logical key', (_layout, key) => { + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key, code: 'KeyC', ctrlKey: true }), { + isMac: true, + hasSelection: false + }) + ).toBe(true) + }) + + it('suppresses the matching keyup for a non-Latin Ctrl+C', () => { + expect( + shouldSuppressTerminalInterruptKeyup( + event({ type: 'keyup', key: 'ㅊ', code: 'KeyC', ctrlKey: true }) + ) + ).toBe(true) + }) + + // The paired negative, and the reason this cannot simply prefer `code`: a Latin layout that + // moves letters around (Dvorak) reports a real ASCII letter, and that letter is authoritative. + it('still ignores a Latin layout that maps KeyC to a different ASCII letter', () => { + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'j', code: 'KeyC', ctrlKey: true }), { + isMac: true, + hasSelection: false + }) + ).toBe(false) + }) + + // And a non-Latin key on some other physical key must not become an interrupt. + it('does not handle a non-Latin logical key on a physical key other than KeyC', () => { + expect( + shouldHandleTerminalInterruptKeyboardEvent( + event({ key: 'ㅁ', code: 'KeyA', ctrlKey: true }), + { + isMac: true, + hasSelection: false + } + ) + ).toBe(false) + }) + + // An IME sits on top of a Latin layout, so the layout map still answers for the physical key. + // Consulting it is what keeps the non-Latin path precise rather than merely positional. + it('uses the layout map when an IME is layered over a Latin layout', () => { + _setLayoutMapForTests(new Map([['KeyC', 'c']])) + expect( + shouldHandleTerminalInterruptKeyboardEvent( + event({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }), + { + isMac: true, + hasSelection: false + } + ) + ).toBe(true) + }) + + // The case positional matching alone would get wrong: Korean layered over Dvorak, where the + // physical KeyC is not the user's C. The layout map says so, and the interrupt declines. + it('declines when the layout map shows the physical key is not C', () => { + _setLayoutMapForTests(new Map([['KeyC', 'j']])) + expect( + shouldHandleTerminalInterruptKeyboardEvent( + event({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }), + { + isMac: true, + hasSelection: false + } + ) + ).toBe(false) + }) + + // A true non-Latin *layout* (not an IME) has a non-Latin map too, so it cannot answer either. + // Fall back to physical position, which is how terminals resolve control chords. + it('falls back to the physical key when the layout map is itself non-Latin', () => { + _setLayoutMapForTests(new Map([['KeyC', 'с']])) + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'с', code: 'KeyC', ctrlKey: true }), { + isMac: true, + hasSelection: false + }) + ).toBe(true) + }) + it('does not handle modified Ctrl+C chords', () => { expect( shouldHandleTerminalInterruptKeyboardEvent( diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index 18a50129e9e..d09ffd4b039 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -1,4 +1,5 @@ import { keybindingMatchesInput } from '../../../../shared/keybindings' +import { getLayoutBaseCharacterForCode } from '../../lib/keyboard-layout/layout-base-character' import { isTerminalImeCandidateDigitKeyEvent, isTerminalImeCandidateSelectionKeyEvent @@ -151,10 +152,33 @@ export function shouldPreventDefaultTerminalImeCandidateKey( ) } +/** + * A logical key a Latin layout could have produced. Only then is `key` authoritative: + * Dvorak moving `c` elsewhere is a real remap and must be honoured. + */ +function isLatinLetterKey(normalizedKey: string): boolean { + return normalizedKey.length === 1 && normalizedKey >= 'a' && normalizedKey <= 'z' +} + function isTerminalInterruptCKey(event: XtermBypassEvent): boolean { const normalizedKey = event.key.toLowerCase() - const logicalKeyAvailable = normalizedKey !== '' && normalizedKey !== 'unidentified' - return logicalKeyAvailable ? normalizedKey === 'c' : event.code === 'KeyC' || event.keyCode === 67 + if (isLatinLetterKey(normalizedKey)) { + return normalizedKey === 'c' + } + // A non-Latin input source reports its own glyph here — a Hangul jamo on Korean 2-Set, + // Cyrillic es on Russian — and cannot express a control chord in `key` at all. Ask the + // layout map what this physical key produces unmodified: for an IME layered over a Latin + // layout that answers `c`, and for a Dvorak base it answers `j`, which correctly declines. + const layoutBaseKey = event.code + ? getLayoutBaseCharacterForCode(event.code)?.toLowerCase() + : undefined + if (layoutBaseKey !== undefined && isLatinLetterKey(layoutBaseKey)) { + return layoutBaseKey === 'c' + } + // Why the physical fallback: on a true non-Latin *layout* the map is non-Latin too, so it + // cannot answer the question either. Terminals resolve control chords by physical position, + // so KeyC is the interrupt. Empty and Unidentified land here as they always did. + return event.code === 'KeyC' || event.keyCode === 67 } function isPlainCtrlC(event: XtermBypassEvent): boolean { diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx index 7887b7978fc..49656d86e12 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx @@ -1,210 +1,84 @@ // @vitest-environment happy-dom - -import { act } from 'react' +import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import { readStoreListenerCount } from '@/store/store-listener-census' -import type { - WorkspaceCleanupCandidate, - WorkspaceCleanupScanResult -} from '../../../../shared/workspace-cleanup' -import type * as WorkspaceCleanupPresentation from './workspace-cleanup-presentation' +import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' import WorkspaceCleanupDialog from './WorkspaceCleanupDialog' -import { NOW, makeCandidate, makeState } from './workspace-cleanup-presentation-fixtures' +import { WorkspaceCleanupScanSupersededError } from '@/store/slices/workspace-cleanup-broad-scan-registry' -const contentProbe = vi.hoisted(() => ({ - mounts: vi.fn(), - projections: vi.fn(), - renders: vi.fn(), - storeNotifications: vi.fn(), - unmounts: vi.fn() -})) +const probes = vi.hoisted(() => ({ facets: vi.fn() })) +const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })) -const toastProbe = vi.hoisted(() => ({ - error: vi.fn(), - info: vi.fn(), - message: vi.fn(), - success: vi.fn(), - warning: vi.fn() -})) - -vi.mock('sonner', () => ({ toast: toastProbe })) - -vi.mock('./workspace-cleanup-presentation', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - filterWorkspaceCleanupCandidates: ( - ...args: Parameters - ) => { - contentProbe.projections() - return actual.filterWorkspaceCleanupCandidates(...args) - }, - getWorkspaceCleanupReviewInfo: ( - ...args: Parameters - ) => { - contentProbe.projections() - return actual.getWorkspaceCleanupReviewInfo(...args) - }, - sortWorkspaceCleanupCandidates: ( - ...args: Parameters - ) => { - contentProbe.projections() - return actual.sortWorkspaceCleanupCandidates(...args) +vi.mock('sonner', () => ({ toast })) +vi.mock('./use-workspace-cleanup-facet-rows', () => ({ + useWorkspaceCleanupFacetRows: () => { + probes.facets() + return { + rows: [], + selectableWorktreeIds: [], + facetMatchedWorktreeIds: new Set(), + matchedCount: 0, + totalCount: 0, + facetCounts: { + activity: 0, + size: 0, + status: 0, + agent: 0, + git: 0, + review: 0, + ticket: 0, + context: 0, + location: 0, + safety: 0 + }, + options: { workspaceStatuses: [], hostIds: [], repos: [], reviewProviders: [] }, + reviewInfoByWorktreeId: new Map(), + sizeByWorktreeId: new Map(), + measuredSizeCount: 0, + unmeasuredSizeCount: 0 } } -}) - +})) vi.mock('@/components/ui/dialog', async () => { - const React = await import('react') - const Passthrough = ({ children }: { children: React.ReactNode }) => <>{children} - const DialogContent = () => { - contentProbe.renders() - React.useEffect(() => { - contentProbe.mounts() - const unsubscribe = useAppStore.subscribe(() => contentProbe.storeNotifications()) - return () => { - contentProbe.unmounts() - unsubscribe() - } - }, []) - // Why: render while closed so the marker measures Orca's content gate, not Radix's portal gate. - return
- } + const Passthrough = ({ children }: { children: ReactNode }) => <>{children} return { Dialog: Passthrough, - DialogContent, - DialogDescription: Passthrough, - DialogFooter: Passthrough, - DialogHeader: Passthrough, - DialogTitle: Passthrough + DialogContent: () =>
} }) -type Deferred = { - promise: Promise - resolve: (value: T) => void +const initialState = useAppStore.getInitialState() +let container: HTMLDivElement +let root: Root + +function emptyScan(scannedAt: number): WorkspaceCleanupScanResult { + return { scannedAt, candidates: [], errors: [] } } -const initialAppState = useAppStore.getInitialState() -const fixtureState = makeState() -const initialCandidate = makeCandidate() -const initialScan = makeScan(1, initialCandidate) -let testContainer: HTMLDivElement -let testRoot: Root - -function deferred(): Deferred { - let resolve!: (value: T) => void - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise - }) - return { promise, resolve } -} - -function makeScan(index: number, candidate: WorkspaceCleanupCandidate): WorkspaceCleanupScanResult { - return { - scannedAt: NOW + index, - candidates: [candidate], - errors: [] - } -} - -function makeChurnCandidate(index: number): WorkspaceCleanupCandidate { - return makeCandidate({ - worktreeId: `repo-1::/repo/background-${index}`, - displayName: `background-${index}`, - path: `/repo/background-${index}`, - fingerprint: `background-${index}` - }) -} - -function activeContentSubscriptions(): number { - return contentProbe.mounts.mock.calls.length - contentProbe.unmounts.mock.calls.length -} - -function requireStoreListenerCount(): number { - const count = readStoreListenerCount() - expect(count).not.toBeNull() - return count ?? 0 -} - -async function flushEffects(): Promise { +async function flush(): Promise { await act(async () => { await Promise.resolve() await Promise.resolve() }) } -async function renderDialog(): Promise { - await act(async () => testRoot.render()) - await flushEffects() -} - async function openDialog(): Promise { await act(async () => useAppStore.getState().openModal('workspace-cleanup')) - await flushEffects() + await flush() } async function closeDialog(): Promise { await act(async () => useAppStore.getState().closeModal()) - await flushEffects() + await flush() } -async function publishScanProgress(index: number): Promise { - const scan = makeScan(index, makeChurnCandidate(index)) - await act(async () => { - useAppStore.setState({ - workspaceCleanupScan: scan, - workspaceCleanupProgress: { - ...scan, - scanId: `scan-${index}`, - scannedWorktreeCount: 1, - totalWorktreeCount: 1 - } - }) - }) -} - -async function publishReviewChurn(index: number): Promise { - await act(async () => { - useAppStore.setState({ - hostedReviewCache: { - [`background-${index}`]: { data: null, fetchedAt: NOW + index } - } - }) - }) -} - -async function publishDeleteChurn(index: number): Promise { - const candidate = makeChurnCandidate(index) - await act(async () => { - useAppStore.setState({ - deleteStateByWorktreeId: { - [candidate.worktreeId]: { - isDeleting: true, - phase: 'queued', - error: null, - canForceDelete: false, - forceDeleteReason: null - } - } - }) - }) -} - -function seedStore( - scanWorkspaceCleanup: AppState['scanWorkspaceCleanup'] = vi.fn(async () => initialScan) -): void { - useAppStore.setState(initialAppState, true) +function seedStore(scanWorkspaceCleanup: AppState['scanWorkspaceCleanup']): void { + useAppStore.setState(initialState, true) useAppStore.setState({ activeModal: 'none', - repos: fixtureState.repos, - worktreesByRepo: fixtureState.worktreesByRepo, - hostedReviewCache: {}, - deleteStateByWorktreeId: {}, - workspaceCleanupScan: initialScan, + workspaceCleanupScan: emptyScan(1), workspaceCleanupProgress: null, workspaceCleanupLoading: false, workspaceCleanupError: null, @@ -216,117 +90,81 @@ describe('WorkspaceCleanupDialog mount gating', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - Object.values(contentProbe).forEach((probe) => probe.mockClear()) - Object.values(toastProbe).forEach((probe) => probe.mockClear()) - seedStore() - testContainer = document.createElement('div') - document.body.appendChild(testContainer) - testRoot = createRoot(testContainer) + probes.facets.mockClear() + toast.error.mockClear() + toast.success.mockClear() + seedStore(vi.fn(async () => emptyScan(2))) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) }) afterEach(async () => { - await act(async () => testRoot.unmount()) + await act(async () => root.unmount()) document.body.replaceChildren() - useAppStore.setState(initialAppState, true) + useAppStore.setState(initialState, true) vi.clearAllTimers() vi.useRealTimers() }) - it('keeps heavy content through 299 ms, then drops its projections and subscriptions', async () => { - const listenerBaseline = requireStoreListenerCount() - await renderDialog() - const shellListenerCount = requireStoreListenerCount() - - expect(shellListenerCount).toBe(listenerBaseline + 1) - expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).toBeNull() - expect(activeContentSubscriptions()).toBe(0) + it('drops heavy projections after the close animation', async () => { + await act(async () => root.render()) + await flush() + expect(probes.facets).not.toHaveBeenCalled() await openDialog() - const openListenerCount = requireStoreListenerCount() - - expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).not.toBeNull() - expect(activeContentSubscriptions()).toBe(1) - expect(openListenerCount).toBeGreaterThan(shellListenerCount) - - await closeDialog() - await act(async () => vi.advanceTimersByTimeAsync(299)) - - expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).not.toBeNull() - expect(activeContentSubscriptions()).toBe(1) - - const lingeringProjectionCount = contentProbe.projections.mock.calls.length - const lingeringNotificationCount = contentProbe.storeNotifications.mock.calls.length - await publishScanProgress(2) - await publishReviewChurn(2) - await publishDeleteChurn(2) - - expect(contentProbe.projections.mock.calls.length).toBeGreaterThan(lingeringProjectionCount) - expect(contentProbe.storeNotifications.mock.calls.length).toBeGreaterThan( - lingeringNotificationCount - ) - - await act(async () => vi.advanceTimersByTimeAsync(1)) - - expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).toBeNull() - expect(activeContentSubscriptions()).toBe(0) - expect(requireStoreListenerCount()).toBe(shellListenerCount) - - const hiddenProjectionCount = contentProbe.projections.mock.calls.length - const hiddenRenderCount = contentProbe.renders.mock.calls.length - const hiddenNotificationCount = contentProbe.storeNotifications.mock.calls.length - for (let index = 3; index < 103; index += 1) { - await publishScanProgress(index) - await publishReviewChurn(index) - await publishDeleteChurn(index) - } - - expect(contentProbe.projections).toHaveBeenCalledTimes(hiddenProjectionCount) - expect(contentProbe.renders).toHaveBeenCalledTimes(hiddenRenderCount) - expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(hiddenNotificationCount) - }) - - it('cancels the pending content unmount when reopened during the linger', async () => { - await renderDialog() - await openDialog() - await closeDialog() - await act(async () => vi.advanceTimersByTimeAsync(299)) - await openDialog() - await act(async () => vi.advanceTimersByTimeAsync(1_000)) - - expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).not.toBeNull() - expect(activeContentSubscriptions()).toBe(1) - - const projectionCount = contentProbe.projections.mock.calls.length - await publishScanProgress(4) - expect(contentProbe.projections.mock.calls.length).toBeGreaterThan(projectionCount) - }) - - it('toasts when an open-time scan settles after the closed content unmounts', async () => { - const pendingScan = deferred() - const scanWorkspaceCleanup = vi.fn(() => pendingScan.promise) - seedStore(scanWorkspaceCleanup) - await renderDialog() - await openDialog() - - expect(scanWorkspaceCleanup).toHaveBeenCalledTimes(1) + expect(probes.facets).toHaveBeenCalled() await closeDialog() await act(async () => vi.advanceTimersByTimeAsync(300)) - expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).toBeNull() + const hiddenCalls = probes.facets.mock.calls.length - const completedScan = makeScan(5, makeChurnCandidate(5)) - await act(async () => { - pendingScan.resolve(completedScan) - await pendingScan.promise - await Promise.resolve() - }) - await flushEffects() - - expect(toastProbe.success).toHaveBeenCalledWith( - 'Inactive workspace scan ready', - expect.objectContaining({ - action: expect.objectContaining({ label: 'Review' }) + for (let index = 0; index < 100; index += 1) { + await act(async () => { + useAppStore.setState({ + workspaceCleanupScan: emptyScan(index + 10), + hostedReviewCache: { [`review-${index}`]: { data: null, fetchedAt: index } }, + deleteStateByWorktreeId: {} + }) }) + } + + expect(probes.facets).toHaveBeenCalledTimes(hiddenCalls) + }) + + it('keeps scan completion ownership after heavy content unmounts', async () => { + let resolveScan!: (result: WorkspaceCleanupScanResult) => void + const scanPromise = new Promise((resolve) => { + resolveScan = resolve + }) + const scanWorkspaceCleanup = vi.fn(() => scanPromise) + seedStore(scanWorkspaceCleanup) + await act(async () => root.render()) + await openDialog() + + await closeDialog() + await act(async () => vi.advanceTimersByTimeAsync(300)) + expect(container.querySelector('[data-workspace-cleanup-content]')).toBeNull() + + await act(async () => resolveScan(emptyScan(20))) + await flush() + + expect(toast.success).toHaveBeenCalledWith( + 'Workspace scan ready', + expect.objectContaining({ action: expect.objectContaining({ label: 'Review' }) }) ) }) + + it('does not report a superseded scan as a failure', async () => { + seedStore( + vi.fn(async () => { + throw new WorkspaceCleanupScanSupersededError() + }) + ) + await act(async () => root.render()) + + await openDialog() + + expect(toast.error).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx new file mode 100644 index 00000000000..204b28317f1 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx @@ -0,0 +1,399 @@ +// @vitest-environment happy-dom +import type { ReactNode, Ref } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WorkspaceCleanupScanProgress, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' +import { createTestStore, seedStore } from '../../store/slices/store-test-helpers' +import { resetWorkspaceCleanupBrowsePersistTimer } from '../../store/slices/workspace-cleanup-browse' +import { NOW, makeCandidate } from '../../store/slices/workspace-cleanup-slice-test-harness' +import WorkspaceCleanupDialog from './WorkspaceCleanupDialog' + +const holders = vi.hoisted(() => ({ + store: null as unknown as { (selector: (s: unknown) => unknown): unknown } & { + getState: () => Record + setState: (partial: unknown) => void + subscribe: (listener: (state: unknown, previous: unknown) => void) => () => void + }, + infoToasts: [] as string[], + rig: null as null | { + resolvers: ((result: WorkspaceCleanupScanResult) => void)[] + } +})) + +vi.mock('@/store', () => { + const useAppStore = (selector: (s: unknown) => unknown): unknown => holders.store(selector) + useAppStore.getState = () => holders.store.getState() + useAppStore.setState = (partial: unknown) => holders.store.setState(partial) + useAppStore.subscribe = (listener: (state: unknown, previous: unknown) => void) => + holders.store.subscribe(listener) + return { useAppStore } +}) + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: (message: unknown) => holders.infoToasts.push(String(message)) + }) +})) + +vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children, viewportRef }: { children: ReactNode; viewportRef?: Ref }) => ( +
}>{children}
+ ), + ScrollBar: () => null +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null +})) + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: () => null +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: () => null, + DropdownMenuItem: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
+})) + +const CACHED_AT = NOW - 2 * 60 * 60 * 1000 + +type ScanRig = { + scan: ReturnType + progressCallbacks: ((progress: WorkspaceCleanupScanProgress) => void)[] + resolvers: ((result: WorkspaceCleanupScanResult) => void)[] +} + +function installApi(cachedScan: WorkspaceCleanupScanResult | null): ScanRig { + const rig: ScanRig = { scan: vi.fn(), progressCallbacks: [], resolvers: [] } + rig.scan.mockImplementation( + ( + args?: { worktreeId?: string }, + onProgress?: (progress: WorkspaceCleanupScanProgress) => void + ) => { + if (args?.worktreeId) { + return Promise.resolve({ + scannedAt: NOW, + candidates: [makeCandidate({ worktreeId: args.worktreeId })], + errors: [] + }) + } + if (onProgress) { + rig.progressCallbacks.push(onProgress) + } + return new Promise((resolve) => { + rig.resolvers.push(resolve) + }) + } + ) + ;(window as unknown as { api: unknown }).api = { + workspaceCleanup: { + scan: rig.scan, + getCachedScan: vi.fn().mockResolvedValue(cachedScan), + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ hasKillableProcesses: false }) + }, + workspaceSpace: { + getCachedAnalysis: vi.fn().mockResolvedValue(null), + analyze: vi.fn(), + cancel: vi.fn().mockResolvedValue(false), + onProgress: vi.fn().mockReturnValue(() => undefined) + }, + ui: { set: vi.fn().mockResolvedValue(undefined) } + } + holders.rig = rig + return rig +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function flush(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +async function renderDialog(): Promise { + await act(async () => { + root?.render() + }) +} + +async function openDialog(): Promise { + await act(async () => { + holders.store.setState({ activeModal: 'workspace-cleanup' }) + }) + await flush() +} + +function rowNames(): string[] { + return [...(container?.querySelectorAll('[data-workspace-cleanup-row-name]') ?? [])].map( + (node) => node.textContent ?? '' + ) +} + +function rowCheckbox(name: string): HTMLElement | null { + return container?.querySelector(`[aria-label="Select ${name}"]`) ?? null +} + +function cachedFleet(): WorkspaceCleanupScanResult { + return { + scannedAt: CACHED_AT, + candidates: [ + makeCandidate({ worktreeId: 'repo1::/tmp/alpha', displayName: 'alpha' }), + makeCandidate({ worktreeId: 'repo1::/tmp/beta', displayName: 'beta' }) + ], + errors: [] + } +} + +function progressTick( + rig: ScanRig, + progress: Partial & Pick +): void { + rig.progressCallbacks.at(-1)?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: progress.candidates.length, + totalWorktreeCount: 3, + errors: [], + candidateMode: 'append', + ...progress + }) +} + +describe('WorkspaceCleanupDialog stale-while-revalidate', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + // Why Date-only fake: relative "Updated Xh ago" labels need a pinned clock, + // but flush() depends on real setTimeout. + vi.useFakeTimers({ toFake: ['Date'], now: NOW }) + const store = createTestStore() + seedStore(store, {}) + holders.store = store as unknown as typeof holders.store + holders.infoToasts = [] + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + if (root) { + act(() => root?.unmount()) + } + // Why: broad-scan dedupe lives in slice module state; an unresolved scan + // left by one case would silently join into the next case's scan call. + for (const resolve of holders.rig?.resolvers ?? []) { + resolve({ scannedAt: NOW, candidates: [], errors: [] }) + } + holders.rig = null + await new Promise((resolve) => setTimeout(resolve, 0)) + container?.remove() + root = null + container = null + resetWorkspaceCleanupBrowsePersistTimer() + vi.useRealTimers() + }) + + it('renders the cached snapshot immediately and kicks one background rescan', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + + // Full list from cache while the rescan is still streaming (unresolved). + expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta'])) + expect(rig.scan).toHaveBeenCalledTimes(1) + expect(container?.textContent).toContain('Updated 2h ago') + expect(container?.textContent).toContain('Refreshing') + }) + + it('reconciles streamed rows into the cached list without clearing it', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + + progressTick(rig, { + candidates: [ + makeCandidate({ + worktreeId: 'repo1::/tmp/alpha', + displayName: 'alpha', + fingerprint: 'fingerprint-alpha-2' + }), + makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' }) + ], + scannedWorktreeCount: 2 + }) + await flush() + + // beta was not re-reported yet; it must still be on screen mid-refresh. + expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma'])) + }) + + it('keeps search and selection interactive while rows stream in', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + + const search = container?.querySelector('[aria-label="Search workspaces"]') + expect(search?.disabled).toBe(false) + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + await act(async () => { + setter?.call(search, 'beta') + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + progressTick(rig, { + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })], + scannedWorktreeCount: 1 + }) + await flush() + + // Filter applied mid-stream: only beta visible even as new rows arrive. + expect(rowNames()).toEqual(['beta']) + + await act(async () => { + setter?.call(search, '') + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + await flush() + expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma'])) + + // Rows stay selectable during the stream. + const checkbox = rowCheckbox('alpha') + expect(checkbox).not.toBeNull() + const before = checkbox?.getAttribute('aria-checked') + await act(async () => { + checkbox?.click() + }) + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).not.toBe(before) + }) + + it('does not clear selection while free-text search hides the row', async () => { + installApi(cachedFleet()) + await renderDialog() + await openDialog() + + if (rowCheckbox('alpha')?.getAttribute('aria-checked') !== 'true') { + await act(async () => rowCheckbox('alpha')?.click()) + } + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + const search = container?.querySelector('[aria-label="Search workspaces"]') + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + await act(async () => { + setter?.call(search, 'beta') + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + await flush() + expect(rowNames()).toEqual(['beta']) + + await act(async () => { + setter?.call(search, '') + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + await flush() + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + }) + + it('adopts the in-flight scan on reopen instead of starting a duplicate', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + expect(rig.scan).toHaveBeenCalledTimes(1) + + progressTick(rig, { + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })], + scannedWorktreeCount: 1 + }) + await flush() + + // Close at 40% and reopen: the same scan keeps streaming into the store. + await act(async () => { + holders.store.setState({ activeModal: null }) + }) + await openDialog() + expect(rig.scan).toHaveBeenCalledTimes(1) + + progressTick(rig, { + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/delta', displayName: 'delta' })], + scannedWorktreeCount: 2 + }) + await flush() + await act(async () => { + rig.resolvers.at(-1)?.({ + scannedAt: NOW, + candidates: [ + ...cachedFleet().candidates, + makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' }), + makeCandidate({ worktreeId: 'repo1::/tmp/delta', displayName: 'delta' }) + ], + errors: [] + }) + }) + await flush() + + expect(rig.scan).toHaveBeenCalledTimes(1) + expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma', 'delta'])) + }) + + it('keeps selections across a refresh and surfaces vanished selections', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + await act(async () => { + rig.resolvers.at(-1)?.(cachedFleet()) + }) + await flush() + + // Both rows are default-selected suggestions after the first settle. + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + expect(rowCheckbox('beta')?.getAttribute('aria-checked')).toBe('true') + + await act(async () => { + container?.querySelector('[aria-label="Refresh"]')?.click() + }) + await flush() + expect(rig.scan).toHaveBeenCalledTimes(2) + // Selection survives while the refresh streams. + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + + await act(async () => { + rig.resolvers.at(-1)?.({ + scannedAt: NOW + 1, + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/alpha', displayName: 'alpha' })], + errors: [] + }) + }) + await flush() + + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + expect(rowNames()).not.toContain('beta') + expect(holders.infoToasts).toContain('1 selected workspace no longer exists.') + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 586618d521b..e732584f266 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -1,245 +1,73 @@ -/* eslint-disable max-lines -- Why: the cleanup dialog keeps scan status, - filters, row actions, localized review copy, and force-aware confirmation - in one modal flow. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { - AlertTriangle, - Info, - Loader2, - RefreshCcw, - Search, - SlidersHorizontal, - Trash2, - X -} from 'lucide-react' +import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Input } from '@/components/ui/input' -import { Progress } from '@/components/ui/progress' -import RepoMultiCombobox from '@/components/ui/repo-multi-combobox' +import { Dialog, DialogContent } from '@/components/ui/dialog' import { ScrollArea } from '@/components/ui/scroll-area' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' -import { cn } from '@/lib/utils' import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { isGitRepoKind } from '../../../../shared/repo-kind' +import { translate } from '@/i18n/i18n' import { canQueueWorkspaceCleanupCandidate, - type WorkspaceCleanupCandidate, - type WorkspaceCleanupScanError, - type WorkspaceCleanupScanProgress + type WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' +import { WorkspaceCleanupBrowseToolbar } from './workspace-cleanup-browse-toolbar' +import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove' +import { WorkspaceCleanupDialogHeader } from './workspace-cleanup-dialog-header' import { - filterWorkspaceCleanupCandidates, - getWorkspaceCleanupReviewInfo, - sortWorkspaceCleanupCandidates, - type WorkspaceCleanupContextFilter, - type WorkspaceCleanupFilters, - type WorkspaceCleanupGitFilter, - type WorkspaceCleanupReviewFilter, - type WorkspaceCleanupReviewInfo, - type WorkspaceCleanupSortDirection, - type WorkspaceCleanupSortKey, - type WorkspaceCleanupTimeFilter -} from './workspace-cleanup-presentation' -import type { WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' -import { countEstimatedInactiveWorkspaces } from './inactive-workspace-estimate' -import { CandidateRow, type WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' -import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' + WorkspaceCleanupInitialScanBanner, + WorkspaceCleanupNotice, + WorkspaceCleanupSkeletonRows +} from './workspace-cleanup-dialog-notices' +import { WorkspaceCleanupRowList } from './workspace-cleanup-row-list' +import { formatWorkspaceCleanupScanNotice } from './workspace-cleanup-scan-notice' import { - getCandidateStatus, - getContextPillLabel, - getDirtyGitLabel, - getReviewPillTone, - shouldShowGitMetadataChip -} from './workspace-cleanup-candidate-row-data' -import { StatusPill } from './workspace-cleanup-status-pill' + applyWorkspaceCleanupGitEvidence, + needsWorkspaceCleanupGitEvidence +} from './workspace-cleanup-git-evidence' +import { useWorkspaceCleanupBrowseState } from './use-workspace-cleanup-browse-state' import { - resolveWorkspaceCleanupActiveView, - type WorkspaceCleanupView, - type WorkspaceCleanupViewCounts -} from './workspace-cleanup-view-selection' + useWorkspaceCleanupDialogLifecycle, + type WorkspaceCleanupDialogLifecycle +} from './use-workspace-cleanup-dialog-lifecycle' +import { useWorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows' +import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evidence' +import { useWorkspaceCleanupRowOrder } from './use-workspace-cleanup-row-order' import { - DEFAULT_WORKSPACE_CLEANUP_FILTERS, - useWorkspaceCleanupDialogSession, - type WorkspaceCleanupDialogSession -} from './workspace-cleanup-dialog-session' -import { translate } from '@/i18n/i18n' - -const WORKSPACE_CLEANUP_CLOSE_LINGER_MS = 300 - -const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = { - hasReview: false, - label: null, - state: null, - provider: null, - title: null -} - -function formatRelativeTime(timestamp: number): string { - if (!timestamp) { - return 'Never' - } - const deltaMs = Date.now() - timestamp - if (deltaMs < 60_000) { - return 'Just now' - } - const minutes = Math.floor(deltaMs / 60_000) - if (minutes < 60) { - return `${minutes}m ago` - } - const hours = Math.floor(minutes / 60) - if (hours < 48) { - return `${hours}h ago` - } - return `${Math.floor(hours / 24)}d ago` -} - -function isDisconnectedRemoteScanError(message: string): boolean { - return ( - message === 'SSH provider is unavailable.' || - message === 'Remote workspaces are not connected. Reconnect and refresh to check them.' - ) -} - -function formatScanNoticeMessage( - errors: WorkspaceCleanupScanError[], - repoNameById: Map -): string | null { - const visibleErrors = errors.filter( - (error) => !isDisconnectedRemoteScanError(error.message ?? '') - ) - if (visibleErrors.length === 0) { - return null - } - if (visibleErrors.length === 1) { - const error = visibleErrors[0] - const repoName = formatScanErrorRepoName(error, repoNameById) - return `Could not check ${repoName}: ${formatScanErrorReason(error.message)}. Some inactive workspaces may be missing. Refresh to try again.` - } - const repoNames = visibleErrors - .slice(0, 3) - .map((error) => formatScanErrorRepoName(error, repoNameById)) - .join(', ') - const moreCount = visibleErrors.length - 3 - const suffix = moreCount > 0 ? `, +${moreCount} more` : '' - return `Could not check ${visibleErrors.length} repositories (${repoNames}${suffix}). Some inactive workspaces may be missing. Refresh to try again.` -} - -function formatScanErrorRepoName( - error: Partial, - repoNameById: Map -): string { - const repoName = error.repoName?.trim() - if (repoName) { - return repoName - } - const fallback = error.repoId ? repoNameById.get(error.repoId)?.trim() : '' - return fallback || 'a repository' -} - -function formatScanErrorReason(message: string | undefined): string { - if (!message) { - return 'Git could not list worktrees' - } - if (message === 'Could not scan workspace cleanup for this repository.') { - return 'Git could not list worktrees' - } - return message.replace(/\.$/, '') -} + formatVanishedSelectionNotice, + getDefaultSelectedWorkspaceCleanupIds, + toggleSetMember +} from './workspace-cleanup-selection-model' +/** One filterable list of every workspace Orca knows about. */ export default function WorkspaceCleanupDialog(): React.JSX.Element | null { - // Why: scans and removals outlive the modal; only the subscribed projection tree may unmount. - const session = useWorkspaceCleanupDialogSession() - const [lingering, setLingering] = useState(session.open) - useEffect(() => { - if (session.open) { - setLingering(true) - return - } - const timer = window.setTimeout(() => setLingering(false), WORKSPACE_CLEANUP_CLOSE_LINGER_MS) - return () => window.clearTimeout(timer) - }, [session.open]) - - if (!session.open && !lingering) { + const lifecycle = useWorkspaceCleanupDialogLifecycle() + if (!lifecycle.mountedContent) { return null } - return + return } function WorkspaceCleanupDialogContent({ - session -}: { - session: WorkspaceCleanupDialogSession -}): React.JSX.Element { - const { - open, - loading, - selectedIds, - setSelectedIds, - expandedRowIds, - setExpandedRowIds, - activeView, - setActiveView, - confirming, - confirmCandidates, - removalProgress, - removalInFlight, - rowFailures, - repoSelection, - setRepoSelection, - filters, - setFilters, - sortKey, - setSortKey, - sortDirection, - setSortDirection, - selectedDefaultsScanAtRef, - removalInFlightRef, - close: closeModal, - markCandidateViewed, - restoreDismissals: resetDismissals, - startScan: startWorkspaceCleanupScan, - ignoreCandidate, - applyScanDefaults, - openConfirmRemove, - cancelConfirmRemove, - backToWorkspaceCleanupList, - confirmRemove - } = session + open, + loading, + closeModal, + selectedIds, + setSelectedIds, + removal, + startWorkspaceCleanupScan +}: WorkspaceCleanupDialogLifecycle): React.JSX.Element { const scan = useAppStore((s) => s.workspaceCleanupScan) const scanProgress = useAppStore((s) => s.workspaceCleanupProgress) const error = useAppStore((s) => s.workspaceCleanupError) + const spaceError = useAppStore((s) => s.workspaceSpaceScanError) const repos = useAppStore((s) => s.repos) - const reviewStateInputs = useAppStore( - useShallow((s) => ({ - worktreesByRepo: s.worktreesByRepo, - hostedReviewCache: s.hostedReviewCache, - repos: s.repos, - settings: s.settings - })) - ) + const markCandidateViewed = useAppStore((s) => s.markWorkspaceCleanupCandidateViewed) + const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates) + const refreshWorkspaceSpace = useAppStore((s) => s.refreshWorkspaceSpace) + const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning) + const workspaceSpaceProgress = useAppStore((s) => s.workspaceSpaceScanProgress) const deletionPhaseByWorktreeId = useAppStore( useShallow((s) => { const phases: Record = {} @@ -256,154 +84,94 @@ function WorkspaceCleanupDialogContent({ [deletionPhaseByWorktreeId] ) + const browse = useWorkspaceCleanupBrowseState() + // Why: facet counts/options are only rendered inside the filter popover; + // tracking its open state lets their O(N) passes skip while it is closed. + const [facetPanelOpen, setFacetPanelOpen] = useState(false) + const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) const [rowsScrollElement, setRowsScrollElement] = useState(null) - const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) - const eligibleRepoIds = useMemo(() => eligibleRepos.map((repo) => repo.id), [eligibleRepos]) + const selectedDefaultsScanAtRef = useRef(null) + const wasOpenRef = useRef(open) + // Why: a refresh completing mid-open must not clobber a selection the user + // already made (or was given) for this open — defaults apply at most once. + const defaultsAppliedForOpenRef = useRef(false) + const selectionTouchedRef = useRef(false) + const mountedRef = useMountedRef() + + // Why: the facet clock must be stable across renders but never older than + // this open — a hydrated snapshot's scannedAt can be days stale, which would + // misbucket idle thresholds and keep dead agent statuses "fresh". + const [openedAt, setOpenedAt] = useState(() => Date.now()) useEffect(() => { - if (!open) { - return + if (open && !wasOpenRef.current) { + defaultsAppliedForOpenRef.current = false + selectionTouchedRef.current = false + setOpenedAt(Date.now()) } - setRepoSelection(new Set(eligibleRepoIds)) - }, [eligibleRepoIds, open, setRepoSelection]) + wasOpenRef.current = open + }, [open]) + + const { removalInFlightRef } = removal const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates]) - const reviewInfoByWorktreeId = useMemo(() => { - const infos = new Map() - for (const candidate of candidates) { - infos.set(candidate.worktreeId, getWorkspaceCleanupReviewInfo(candidate, reviewStateInputs)) - } - return infos - }, [candidates, reviewStateInputs]) - const effectiveRepoSelection = useMemo>(() => { - if (repoSelection.size > 0 || eligibleRepoIds.length === 0) { - return repoSelection - } - return new Set(eligibleRepoIds) - }, [eligibleRepoIds, repoSelection]) - const selectedScanErrors = useMemo( - () => (scan?.errors ?? []).filter((error) => effectiveRepoSelection.has(error.repoId)), - [effectiveRepoSelection, scan?.errors] - ) - const filteredCandidates = useMemo(() => { - if ( - effectiveRepoSelection.size === 0 || - effectiveRepoSelection.size === eligibleRepoIds.length - ) { - return candidates - } - return candidates.filter((candidate) => effectiveRepoSelection.has(candidate.repoId)) - }, [candidates, effectiveRepoSelection, eligibleRepoIds.length]) - // Why: the Resource Manager button counts from the renderer's activity record only, - // so it routinely disagrees with the scanned list. Reconcile it rather than leaving - // the user to wonder which number is real. - const estimatedInactiveCount = useMemo(() => { - if (!open) { - return null - } - return countEstimatedInactiveWorkspaces( - Object.values(reviewStateInputs.worktreesByRepo).flat(), - new Map(reviewStateInputs.repos.map((repo) => [repo.id, repo])), - Date.now() - ) - }, [open, reviewStateInputs]) - // Why: the reconciliation only holds for a complete scan; a failed or partial - // one would blame the difference on git-history checks instead of the error. - const estimateMismatchNotice = - !loading && - !error && - selectedScanErrors.length === 0 && - scan && - estimatedInactiveCount !== null && - estimatedInactiveCount !== candidates.length && - filteredCandidates.length === candidates.length - ? translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.f637f63882', - "Resource Manager counts {{value0}}; this list found {{value1}}. That counter reads Orca's activity record alone, while this scan also checks each workspace's git history and skips disconnected remotes.", - { value0: estimatedInactiveCount, value1: candidates.length } - ) - : null - - useEffect(() => { - if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { - return - } - selectedDefaultsScanAtRef.current = scan.scannedAt - applyScanDefaults(scan.candidates, deletingWorktreeIds) - }, [applyScanDefaults, deletingWorktreeIds, loading, scan, selectedDefaultsScanAtRef]) - - const visibleCandidates = useMemo(() => { - const rows = filteredCandidates.filter((candidate) => !candidate.blockers.includes('dismissed')) - return sortWorkspaceCleanupCandidates(rows, 'activity', 'asc', reviewInfoByWorktreeId) - }, [filteredCandidates, reviewInfoByWorktreeId]) - const hiddenCandidates = useMemo( - () => - sortWorkspaceCleanupCandidates( - filteredCandidates.filter((candidate) => candidate.blockers.includes('dismissed')), - 'activity', - 'asc', - reviewInfoByWorktreeId - ), - [filteredCandidates, reviewInfoByWorktreeId] - ) - const groups = useMemo( - () => ({ - ready: visibleCandidates.filter((candidate) => candidate.tier === 'ready'), - review: visibleCandidates.filter((candidate) => candidate.tier === 'review'), - protected: visibleCandidates.filter((candidate) => candidate.tier === 'protected') - }), - [visibleCandidates] - ) - const cleanupViewCounts = useMemo( - () => ({ - ready: groups.ready.length, - review: groups.review.length, - protected: groups.protected.length, - hidden: hiddenCandidates.length - }), - [groups.protected.length, groups.ready.length, groups.review.length, hiddenCandidates.length] - ) - const resolvedActiveView = resolveWorkspaceCleanupActiveView({ - requestedView: activeView, - counts: cleanupViewCounts, - open, - loading, - hasScan: scan != null + const gitEvidenceNeeded = open && needsWorkspaceCleanupGitEvidence(browse.filters, browse.sort) + const gitEvidence = useWorkspaceCleanupGitEvidence({ + enabled: gitEvidenceNeeded, + candidates, + scannedAt: scan?.scannedAt ?? null }) - const repoNameById = useMemo( - () => new Map(repos.map((repo) => [repo.id, repo.displayName || repo.path])), - [repos] + const evidencedCandidates = useMemo( + () => applyWorkspaceCleanupGitEvidence(candidates, gitEvidence.evidenceByWorktreeId), + [candidates, gitEvidence.evidenceByWorktreeId] ) + // Why: a live clock would re-run every facet on each render; the newer of + // scan time and open time is the honest stable "as of" moment — a fresh scan + // keeps its timestamp, a stale hydrated snapshot is judged from this open. + const facetNow = Math.max(scan?.scannedAt ?? 0, openedAt) + const facetRows = useWorkspaceCleanupFacetRows({ + candidates: evidencedCandidates, + filters: browse.filters, + sort: browse.sort, + now: facetNow, + facetPanelOpen + }) + const rows = useWorkspaceCleanupRowOrder({ + rows: facetRows.rows, + streaming: loading, + sort: browse.sort + }) + const candidateIds = useMemo( + () => new Set(candidates.map((candidate) => candidate.worktreeId)), + [candidates] + ) + const scanNoticeMessage = useMemo( - () => formatScanNoticeMessage(selectedScanErrors, repoNameById), - [repoNameById, selectedScanErrors] - ) - const hasAnyCandidates = candidates.length > 0 - const initialLoading = loading && !hasAnyCandidates - const activeBaseRows = - resolvedActiveView === 'hidden' ? hiddenCandidates : groups[resolvedActiveView] - const activeRows = useMemo( () => - sortWorkspaceCleanupCandidates( - filterWorkspaceCleanupCandidates( - activeBaseRows, - filters, - reviewInfoByWorktreeId, - scan?.scannedAt ?? Date.now() - ), - sortKey, - sortDirection, - reviewInfoByWorktreeId + formatWorkspaceCleanupScanNotice( + scan?.errors ?? [], + new Map(repos.map((repo) => [repo.id, repo.displayName || repo.path])) ), - [activeBaseRows, filters, reviewInfoByWorktreeId, scan?.scannedAt, sortDirection, sortKey] + [repos, scan?.errors] ) - const activeRowIds = useMemo( - () => new Set(activeRows.map((candidate) => candidate.worktreeId)), - [activeRows] - ) - const activeFilters = hasActiveWorkspaceCleanupFilters(filters) + const initialLoading = loading && candidates.length === 0 + const runSpaceScan = useCallback(() => { + void refreshWorkspaceSpace().catch((scanError: unknown) => { + if (!mountedRef.current) { + return + } + toast.error( + translate( + 'components.workspace.cleanup.browse.measureSizesFailed', + 'Could not scan workspace sizes' + ), + { description: scanError instanceof Error ? scanError.message : String(scanError) } + ) + }) + }, [mountedRef, refreshWorkspaceSpace]) + const selectedCandidates = useMemo(() => { - const byId = new Map(activeRows.map((candidate) => [candidate.worktreeId, candidate])) + const byId = new Map(rows.map((row) => [row.worktreeId, row.candidate])) return [...selectedIds] .map((id) => byId.get(id)) .filter( @@ -412,59 +180,140 @@ function WorkspaceCleanupDialogContent({ canQueueWorkspaceCleanupCandidate(candidate) && !deletingWorktreeIds.has(candidate.worktreeId) ) - }, [activeRows, deletingWorktreeIds, selectedIds]) + }, [deletingWorktreeIds, rows, selectedIds]) + const selectedCount = selectedCandidates.length + useEffect(() => { - if (!open || confirming) { + if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { return } - // Why: destructive selection must stay scoped to the rows the user can - // currently review after tier/filter changes. + selectedDefaultsScanAtRef.current = scan.scannedAt + if (removalInFlightRef.current) { + return + } + if (defaultsAppliedForOpenRef.current || selectionTouchedRef.current) { + return + } + defaultsAppliedForOpenRef.current = true + setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds)) + }, [deletingWorktreeIds, loading, removalInFlightRef, scan, setSelectedIds]) + + const pruneSelectionToVisibleRows = useEffectEvent(() => { setSelectedIds((current) => { const next = new Set( - [...current].filter((id) => activeRowIds.has(id) && !deletingWorktreeIds.has(id)) + [...current].filter( + (id) => facetRows.facetMatchedWorktreeIds.has(id) && !deletingWorktreeIds.has(id) + ) ) return next.size === current.size ? current : next }) - }, [activeRowIds, confirming, deletingWorktreeIds, open, setSelectedIds]) + }) - const handleOpenChange = useCallback( - (nextOpen: boolean) => { - if (!nextOpen) { - closeModal() - } + useEffect(() => { + if (!open || removal.confirming) { + return + } + // Why: destructive selection must stay scoped to the rows the user can + // currently review after a filter change — and only then; keying on the + // user's filter state (not the matched set, whose identity changes every + // streaming tick) keeps a re-classified row from being silently deselected. + pruneSelectionToVisibleRows() + }, [browse.filters, open, removal.confirming]) + + const pruneVanishedSelections = useEffectEvent(() => { + const kept = [...selectedIds].filter( + (id) => candidateIds.has(id) && !deletingWorktreeIds.has(id) + ) + if (kept.length === selectedIds.size) { + return + } + const vanishedCount = [...selectedIds].filter( + (id) => !candidateIds.has(id) && !deletingWorktreeIds.has(id) + ).length + setSelectedIds(new Set(kept)) + if (vanishedCount > 0) { + toast.info(formatVanishedSelectionNotice(vanishedCount)) + } + }) + + useEffect(() => { + if (loading) { + return + } + // Why: a settled refresh reconciles by worktreeId — selections survive it, + // and only rows that truly no longer exist drop (with a visible reason). + pruneVanishedSelections() + }, [candidateIds, deletingWorktreeIds, loading]) + + const ignoreCandidate = useCallback( + (candidate: WorkspaceCleanupCandidate) => { + void dismissCandidates([candidate]) + .then(() => { + if (mountedRef.current) { + setSelectedIds((current) => { + const next = new Set(current) + next.delete(candidate.worktreeId) + return next + }) + } + }) + .catch((err: unknown) => { + if (mountedRef.current) { + toast.error( + translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7f451a3e2c', + 'Could not ignore cleanup suggestion' + ), + { description: err instanceof Error ? err.message : String(err) } + ) + } + }) }, - [closeModal] + [dismissCandidates, mountedRef, setSelectedIds] ) - const refresh = useCallback(() => { - startWorkspaceCleanupScan({ notifyWhenReady: true }) - }, [startWorkspaceCleanupScan]) - - const toggleExpandedRow = useCallback( - (worktreeId: string) => { - setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) - }, - [setExpandedRowIds] - ) + const toggleExpandedRow = useCallback((worktreeId: string) => { + setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) + }, []) const toggleSelectedRow = useCallback( (worktreeId: string) => { + selectionTouchedRef.current = true setSelectedIds((current) => toggleSetMember(current, worktreeId)) }, [setSelectedIds] ) + const selectableWorktreeIds = useMemo( + () => + removal.removalInFlight + ? [] + : facetRows.selectableWorktreeIds.filter((id) => !deletingWorktreeIds.has(id)), + [deletingWorktreeIds, facetRows.selectableWorktreeIds, removal.removalInFlight] + ) + const toggleSelectAll = useCallback( + (selectAll: boolean) => { + selectionTouchedRef.current = true + setSelectedIds(selectAll ? new Set(selectableWorktreeIds) : new Set()) + }, + [selectableWorktreeIds, setSelectedIds] + ) + // Why: stable per-row handlers so React.memo keeps unchanged CandidateRow // instances from re-rendering on scan stream-in and selection changes. + const openConfirmRemove = removal.openConfirmRemove const handleRemoveRow = useCallback( (candidate: WorkspaceCleanupCandidate) => { - if (loading || removalInFlightRef.current) { + // Why: rows stay actionable while a rescan streams; the confirm-time + // preflight is the safety gate, not the scan state. + if (removalInFlightRef.current) { return } + selectionTouchedRef.current = true setSelectedIds(new Set([candidate.worktreeId])) openConfirmRemove([candidate]) }, - [loading, openConfirmRemove, removalInFlightRef, setSelectedIds] + [openConfirmRemove, removalInFlightRef, setSelectedIds] ) const handleViewCandidate = useCallback( @@ -476,911 +325,102 @@ function WorkspaceCleanupDialogContent({ [closeModal, markCandidateViewed] ) - const selectedCount = selectedCandidates.length + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + closeModal() + } + }, + [closeModal] + ) return ( - {!confirming ? ( + {!removal.confirming ? ( <> - -
-
- - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.b2c1331844', - 'Delete Inactive Workspaces' - )} - -
-
- - - - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7ae2ad30f4', - 'Refresh' - )} - - - -
-
-
+ openConfirmRemove(selectedCandidates)} + onRefresh={() => startWorkspaceCleanupScan({ notifyWhenReady: true })} + onClose={closeModal} + /> - {initialLoading ? ( -
- -
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7eee951968', - 'Checking inactive workspaces' - )} -
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.47123d0108', - 'Scanning inactive workspaces. You can close this and come back.' - )} -
-
- {formatWorkspaceCleanupProgress(scanProgress)} -
-
-
- ) : hasAnyCandidates ? ( -
-
-
- {selectedCount}{' '} - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ac5ba84cc1', - 'selected' - )} -
-
-
- {eligibleRepos.length > 1 ? ( -
- setRepoSelection(new Set(next))} - onSelectAll={() => setRepoSelection(new Set(eligibleRepoIds))} - triggerClassName="h-8 w-full rounded-md border border-border/60 bg-background px-2 text-xs font-medium shadow-xs hover:bg-accent/60" - /> -
- ) : null} - -
-
+ {initialLoading ? : null} + {error ? : null} + {!error && spaceError ? ( + + ) : null} + {!error && scanNoticeMessage ? ( + ) : null} - {loading && scan && hasAnyCandidates ? ( -
-
- - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9a3be9f2df', - 'Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.' - )} - - - {formatWorkspaceCleanupProgress(scanProgress)} - -
-
- ) : null} + - {error ? ( -
- {error} + +
+ {initialLoading ? : null} +
- ) : scanNoticeMessage ? ( -
- - {scanNoticeMessage} -
- ) : null} - - {estimateMismatchNotice ? ( -
- - {estimateMismatchNotice} -
- ) : null} - -
- -
- {filteredCandidates.length > 0 ? ( - 0 - } - sortKey={sortKey} - sortDirection={sortDirection} - onFiltersChange={setFilters} - onSortKeyChange={setSortKey} - onSortDirectionChange={setSortDirection} - onRestoreIgnored={() => void resetDismissals()} - /> - ) : null} - -
- {initialLoading ? : null} - {!loading && scan && candidates.length === 0 && !scanNoticeMessage ? ( - - ) : null} - {!loading && scan && candidates.length === 0 && scanNoticeMessage ? ( - - ) : null} - {!loading && - scan && - candidates.length > 0 && - filteredCandidates.length === 0 ? ( - setRepoSelection(new Set(eligibleRepoIds))} - /> - ) : null} - {!loading && - scan && - filteredCandidates.length > 0 && - visibleCandidates.length === 0 ? ( - setActiveView('hidden')} - /> - ) : null} - {!loading && - scan && - activeRows.length === 0 && - activeBaseRows.length > 0 && - activeFilters ? ( - setFilters(DEFAULT_WORKSPACE_CLEANUP_FILTERS)} - /> - ) : null} - {!loading && - scan && - activeRows.length === 0 && - visibleCandidates.length > 0 && - !activeFilters ? ( - - ) : null} - ( - 1 && index === activeRows.length - 1} - expanded={expandedRowIds.has(candidate.worktreeId)} - lastActivityLabel={formatRelativeTime(candidate.lastActivityAt)} - deletionPhase={deletionPhaseByWorktreeId[candidate.worktreeId]} - removing={ - loading || - removalInFlight || - deletingWorktreeIds.has(candidate.worktreeId) - } - selected={ - selectedIds.has(candidate.worktreeId) && - !loading && - !deletingWorktreeIds.has(candidate.worktreeId) - } - failure={rowFailures[candidate.worktreeId]} - onToggleExpanded={toggleExpandedRow} - onToggleSelected={toggleSelectedRow} - onView={handleViewCandidate} - onIgnore={ignoreCandidate} - onRemove={handleRemoveRow} - /> - )} - /> -
-
-
-
+
) : ( - )}
) } - -function WorkspaceCleanupFilterToolbar({ - filters, - showRestoreIgnored, - sortKey, - sortDirection, - onFiltersChange, - onSortKeyChange, - onSortDirectionChange, - onRestoreIgnored -}: { - filters: WorkspaceCleanupFilters - showRestoreIgnored: boolean - sortKey: WorkspaceCleanupSortKey - sortDirection: WorkspaceCleanupSortDirection - onFiltersChange: (filters: WorkspaceCleanupFilters) => void - onSortKeyChange: (sortKey: WorkspaceCleanupSortKey) => void - onSortDirectionChange: (direction: WorkspaceCleanupSortDirection) => void - onRestoreIgnored: () => void -}): React.JSX.Element { - const updateFilter = ( - key: K, - value: WorkspaceCleanupFilters[K] - ): void => { - onFiltersChange({ ...filters, [key]: value }) - } - const hasHiddenControls = hasActiveWorkspaceCleanupPanelControls(filters, sortKey, sortDirection) - const resetPanelControls = (): void => { - onFiltersChange({ - ...filters, - time: 'all', - review: 'all', - git: 'all', - context: 'all' - }) - onSortKeyChange('activity') - onSortDirectionChange('asc') - } - - return ( -
-
- - updateFilter('query', event.target.value)} - placeholder={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.searchPlaceholder', - 'Search workspaces' - )} - className="h-8 pl-8 text-xs" - /> -
- - - - - - - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.efb3843e75', - 'Filter and sort workspaces' - )} - - - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.93b7381d50', - 'Filters' - )} - - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ageFilter', - 'Age' - )} - value={filters.time} - options={[ - ['all', 'Any age'], - ['30d', '30d+'], - ['90d', '90d+'], - ['archived', 'Archived'] - ]} - onChange={(value) => updateFilter('time', value)} - /> - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.reviewFilter', - 'Review' - )} - value={filters.review} - options={[ - ['all', 'Any review'], - ['no-review', 'No PR/MR'], - ['has-review', 'Has PR/MR'], - ['open-review', 'Open'], - ['closed-review', 'Closed'] - ]} - onChange={(value) => updateFilter('review', value)} - /> - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.gitFilter', - 'Git' - )} - value={filters.git} - options={[ - ['all', 'Any git'], - ['clean', 'Clean'], - ['dirty', 'Dirty'], - ['unpushed', 'Unpushed'], - ['unknown', 'Unknown'] - ]} - onChange={(value) => updateFilter('git', value)} - /> - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.contextFilter', - 'Context' - )} - value={filters.context} - options={[ - ['all', 'Any context'], - ['has-context', 'Has context'], - ['no-context', 'No context'] - ]} - onChange={(value) => updateFilter('context', value)} - /> - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.a615e24679', - 'Sort' - )} - - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.sortBy', - 'Sort by' - )} - value={sortKey} - options={[ - ['activity', 'Activity'], - ['name', 'Name'], - ['repo', 'Repo'], - ['review', 'Review'], - ['git', 'Git'] - ]} - onChange={onSortKeyChange} - /> - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.sortDirection', - 'Direction' - )} - value={sortDirection} - options={[ - ['asc', 'Ascending'], - ['desc', 'Descending'] - ]} - onChange={onSortDirectionChange} - /> - {showRestoreIgnored ? ( - <> - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.aaee139eab', - 'Restore ignored suggestions' - )} - - - ) : null} - {hasHiddenControls ? ( - <> - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e94b1f8bb4', - 'Clear filters' - )} - - - ) : null} - - -
- ) -} - -function WorkspaceCleanupMenuSub({ - label, - value, - options, - onChange -}: { - label: string - value: T - options: readonly (readonly [T, string])[] - onChange: (value: T) => void -}): React.JSX.Element { - const valueLabel = options.find(([optionValue]) => optionValue === value)?.[1] ?? value - return ( - - - - {label} - - {valueLabel} - - - - - onChange(next as T)}> - {options.map(([optionValue, optionLabel]) => ( - event.preventDefault()} - > - {optionLabel} - - ))} - - - - ) -} - -function CleanupViewNav({ - activeView, - counts, - onViewChange -}: { - activeView: WorkspaceCleanupView - counts: WorkspaceCleanupViewCounts - onViewChange: (view: WorkspaceCleanupView) => void -}): React.JSX.Element { - const items: { view: WorkspaceCleanupView; label: string }[] = [ - { - view: 'ready', - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4b93a235d8', - 'Suggested' - ) - }, - { - view: 'review', - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.d1094dd529', - 'Needs review' - ) - }, - { - view: 'protected', - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4f4782c02', - 'Not suggested' - ) - }, - { - view: 'hidden', - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', - 'Ignored' - ) - } - ] - - return ( - - ) -} - -function ConfirmRemove({ - candidates, - reviewInfoByWorktreeId, - progress, - onBack, - onCancel, - onConfirm -}: { - candidates: WorkspaceCleanupCandidate[] - reviewInfoByWorktreeId: ReadonlyMap - progress: WorkspaceCleanupRemovalProgress | null - onBack: () => void - onCancel: () => void - onConfirm: () => void -}): React.JSX.Element { - const count = candidates.length - const deleting = progress !== null - const progressValue = progress - ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) - : 0 - return ( - <> - -
-
-
- {deleting ? ( - - ) : ( - - )} -
-
- - {deleting - ? translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deletingCount', - 'Deleting workspaces: {{value0}}', - { value0: count } - ) - : translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteCount', - 'Delete workspaces: {{value0}}?', - { value0: count } - )} - - - {deleting - ? translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1d3503357d', - 'You can close this and come back while deletion continues.' - ) - : translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400', - "This permanently deletes their local files. You can't undo this." - )} - -
-
- -
-
-
- {progress ? ( -
-
- - - {formatWorkspaceCleanupRemovalProgress(progress)} - -
- -
- ) : null} -
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.selectedForDeletionCount', - 'Selected for deletion: {{value0}}', - { value0: count } - )} -
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.592fbab446', - 'Sorted by oldest activity' - )} -
-
- - {candidates.map((candidate, index) => ( - - ))} - -
- - - {!deleting ? ( - - ) : null} - - - ) -} - -function ConfirmRemoveRow({ - candidate, - reviewInfo, - last -}: { - candidate: WorkspaceCleanupCandidate - reviewInfo: WorkspaceCleanupReviewInfo - last: boolean -}): React.JSX.Element { - const dirtyLabel = getDirtyGitLabel(candidate) - const branchDiffersFromName = candidate.branch !== candidate.displayName - const contextPillLabel = getContextPillLabel(candidate) - const showGitMetadataChip = shouldShowGitMetadataChip(candidate) - const status = getCandidateStatus(candidate) - return ( -
-
- {candidate.displayName} - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc', - 'Last active' - )}{' '} - {formatRelativeTime(candidate.lastActivityAt)} - - {status.label} - {reviewInfo.label ? ( - {reviewInfo.label} - ) : null} - {contextPillLabel ? {contextPillLabel} : null} - {dirtyLabel && showGitMetadataChip ? ( - {dirtyLabel} - ) : null} -
-
- {candidate.repoName} - {branchDiffersFromName ? ( - <> - - {candidate.branch} - - ) : null} -
-
- {candidate.path} -
-
- ) -} - -function hasActiveWorkspaceCleanupFilters(filters: WorkspaceCleanupFilters): boolean { - return ( - filters.query.trim() !== '' || - filters.time !== 'all' || - filters.review !== 'all' || - filters.git !== 'all' || - filters.context !== 'all' - ) -} - -function hasActiveWorkspaceCleanupPanelControls( - filters: WorkspaceCleanupFilters, - sortKey: WorkspaceCleanupSortKey, - sortDirection: WorkspaceCleanupSortDirection -): boolean { - return ( - filters.time !== 'all' || - filters.review !== 'all' || - filters.git !== 'all' || - filters.context !== 'all' || - sortKey !== 'activity' || - sortDirection !== 'asc' - ) -} - -function formatWorkspaceCleanupRemovalProgress(progress: WorkspaceCleanupRemovalProgress): string { - const deletedText = translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4c2990886e', - '{{value0}}/{{value1}} deleted', - { - value0: progress.removedCount, - value1: progress.totalCount - } - ) - if (progress.failedCount === 0) { - return deletedText - } - return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.86ba852118', - '{{value0}}, {{value1}} failed', - { - value0: deletedText, - value1: progress.failedCount - } - ) -} - -function formatWorkspaceCleanupProgress(progress: WorkspaceCleanupScanProgress | null): string { - if (!progress || progress.scannedWorktreeCount === 0) { - return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe', - 'Finding inactive workspaces...' - ) - } - return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181', - 'Checked workspaces so far: {{value0}}', - { - value0: progress.scannedWorktreeCount - } - ) -} - -function SkeletonRows(): React.JSX.Element { - return ( -
- {[0, 1, 2].map((index) => ( -
- ))} -
- ) -} - -function EmptyState({ - title, - actionLabel, - onAction -}: { - title: string - actionLabel?: string - onAction?: () => void -}): React.JSX.Element { - return ( -
- {title} - {actionLabel && onAction ? ( - - ) : null} -
- ) -} - -function toggleSetMember(current: Set, value: string): Set { - const next = new Set(current) - if (next.has(value)) { - next.delete(value) - } else { - next.add(value) - } - return next -} diff --git a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.test.ts b/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.test.ts deleted file mode 100644 index 0cb2b0329d0..00000000000 --- a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Repo } from '../../../../shared/repo-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { countEstimatedInactiveWorkspaces } from './inactive-workspace-estimate' - -const NOW = 1_800_000_000_000 -const DAY_MS = 24 * 60 * 60 * 1000 - -const GIT_REPO = { id: 'repo-1', path: '/repo', displayName: 'Repo' } as Repo -const FOLDER_REPO = { - id: 'folder-1', - path: '/folder', - displayName: 'Folder', - kind: 'folder' -} as Repo - -const REPOS = new Map([ - [GIT_REPO.id, GIT_REPO], - [FOLDER_REPO.id, FOLDER_REPO] -]) - -function makeWorktree(overrides: Partial = {}): Worktree { - return { - id: 'repo-1::/repo-feature', - repoId: 'repo-1', - path: '/repo-feature', - isMainWorktree: false, - isArchived: false, - lastActivityAt: NOW - 90 * DAY_MS, - ...overrides - } as Worktree -} - -describe('countEstimatedInactiveWorkspaces', () => { - it('counts workspaces idle past the cleanup threshold', () => { - expect(countEstimatedInactiveWorkspaces([makeWorktree()], REPOS, NOW)).toBe(1) - }) - - it('does not count a missing activity stamp as ancient', () => { - const worktrees = [ - makeWorktree({ lastActivityAt: 0 }), - makeWorktree({ lastActivityAt: Number.NaN }) - ] - - expect(countEstimatedInactiveWorkspaces(worktrees, REPOS, NOW)).toBe(0) - }) - - it('falls back to the creation stamp when activity was never recorded', () => { - const recent = makeWorktree({ lastActivityAt: 0, createdAt: NOW - DAY_MS }) - const old = makeWorktree({ lastActivityAt: 0, createdAt: NOW - 90 * DAY_MS }) - - expect(countEstimatedInactiveWorkspaces([recent, old], REPOS, NOW)).toBe(1) - }) - - it('skips main worktrees, folder workspaces, and unknown repos', () => { - const worktrees = [ - makeWorktree({ isMainWorktree: true }), - makeWorktree({ repoId: FOLDER_REPO.id }), - makeWorktree({ repoId: 'gone' }) - ] - - expect(countEstimatedInactiveWorkspaces(worktrees, REPOS, NOW)).toBe(0) - }) - - it('uses the shorter archived threshold', () => { - const worktree = makeWorktree({ isArchived: true, lastActivityAt: NOW - 10 * DAY_MS }) - - expect(countEstimatedInactiveWorkspaces([worktree], REPOS, NOW)).toBe(1) - }) -}) diff --git a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts b/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts deleted file mode 100644 index 23524b5e904..00000000000 --- a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Repo } from '../../../../shared/repo-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { isFolderRepo } from '../../../../shared/repo-kind' -import { - getPersistedWorkspaceCleanupActivityAt, - isWorkspaceOldForCleanup -} from '../../../../shared/workspace-cleanup' - -/** - * Renderer-side estimate of how many workspaces the cleanup scan would list. It - * sees only Orca's persisted activity record, so it disagrees with the scan by - * design — the scan additionally reads each worktree's git history and cannot - * inspect disconnected remotes. - */ -export function countEstimatedInactiveWorkspaces( - worktrees: readonly Worktree[], - repoById: ReadonlyMap, - now: number -): number { - let count = 0 - for (const worktree of worktrees) { - const repo = repoById.get(worktree.repoId) - if (!repo || isFolderRepo(repo) || worktree.isMainWorktree) { - continue - } - // Why: an unstamped workspace (externally created, or never opened here) reads as - // epoch 0, which counted every one of them as idle and inflated the estimate far - // past what the scan lists. Unknown is not old. - const lastActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) - if ( - lastActivityAt > 0 && - isWorkspaceOldForCleanup({ isArchived: worktree.isArchived, lastActivityAt }, now) - ) { - count += 1 - } - } - return count -} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts new file mode 100644 index 00000000000..948928b9a93 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts @@ -0,0 +1,68 @@ +import { useCallback } from 'react' +import { useAppStore } from '@/store' +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState, + type WorkspaceCleanupSortField, + type WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' + +export type WorkspaceCleanupBrowseController = { + filters: WorkspaceCleanupFilterState + sort: WorkspaceCleanupSortState + patchFilters: ( + key: K, + value: Partial | WorkspaceCleanupFilterState[K] + ) => void + toggleSortField: (field: WorkspaceCleanupSortField) => void + clearFilters: () => void +} + +/** + * Single seam over the persisted browse slice so reopening the dialog keeps the + * user's filters and sort. + */ +export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseController { + const browse = useAppStore((s) => s.workspaceCleanupBrowse) + const updateBrowse = useAppStore((s) => s.updateWorkspaceCleanupBrowseState) + + const patchFilters = useCallback( + (key, value) => { + const current = browse.filters[key] + const next = + typeof current === 'object' && current !== null + ? { ...current, ...(value as object) } + : value + // Cast: a computed key over a union widens the spread result past + // WorkspaceCleanupFilterState even though `key` is constrained to it. + const filters = { ...browse.filters, [key]: next } as WorkspaceCleanupFilterState + updateBrowse({ ...browse, filters }) + }, + [browse, updateBrowse] + ) + + // Why: re-picking the active sort flips its direction. + const toggleSortField = useCallback( + (field: WorkspaceCleanupSortField) => { + const direction = + browse.sort.field === field && browse.sort.direction === 'asc' ? 'desc' : 'asc' + updateBrowse({ ...browse, sort: { field, direction } }) + }, + [browse, updateBrowse] + ) + + const clearFilters = useCallback(() => { + updateBrowse({ + ...browse, + filters: createDefaultWorkspaceCleanupFilterState() + }) + }, [browse, updateBrowse]) + + return { + filters: browse.filters, + sort: browse.sort, + patchFilters, + toggleSortField, + clearFilters + } +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts new file mode 100644 index 00000000000..194ed95240b --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react' +import { useAppStore } from '@/store' +import { + useWorkspaceCleanupRemoval, + type WorkspaceCleanupRemovalController +} from './use-workspace-cleanup-removal' +import { useWorkspaceCleanupScanLifecycle } from './use-workspace-cleanup-scan-lifecycle' + +const WORKSPACE_CLEANUP_CLOSE_LINGER_MS = 300 + +export type WorkspaceCleanupDialogLifecycle = { + open: boolean + mountedContent: boolean + loading: boolean + closeModal: () => void + selectedIds: Set + setSelectedIds: Dispatch>> + removal: WorkspaceCleanupRemovalController + startWorkspaceCleanupScan: (options?: { notifyWhenReady?: boolean }) => void +} + +/** Keeps scan/removal ownership mounted while the closed dialog drops its projection tree. */ +export function useWorkspaceCleanupDialogLifecycle(): WorkspaceCleanupDialogLifecycle { + const activeModal = useAppStore((s) => s.activeModal) + const closeModal = useAppStore((s) => s.closeModal) + const loading = useAppStore((s) => s.workspaceCleanupLoading) + const open = activeModal === 'workspace-cleanup' + const [selectedIds, setSelectedIds] = useState>(() => new Set()) + + const deselectRemovedIds = useCallback((removedIds: readonly string[]) => { + if (removedIds.length === 0) { + return + } + setSelectedIds((current) => { + const next = new Set(current) + for (const id of removedIds) { + next.delete(id) + } + return next + }) + }, []) + const removal = useWorkspaceCleanupRemoval({ onDeselect: deselectRemovedIds, closeModal }) + const { removalInFlightRef, resetForReopen, resetRowFailures } = removal + + const onFreshOpen = useCallback(() => { + if (!removalInFlightRef.current) { + resetForReopen() + setSelectedIds(new Set()) + } + }, [removalInFlightRef, resetForReopen]) + + const { startWorkspaceCleanupScan } = useWorkspaceCleanupScanLifecycle({ + open, + loading, + removalInFlight: removal.removalInFlight, + removalInFlightRef, + resetRowFailures, + onFreshOpen + }) + const [mountedContent, setMountedContent] = useState(open) + useEffect(() => { + if (open) { + setMountedContent(true) + return + } + const timer = window.setTimeout( + () => setMountedContent(false), + WORKSPACE_CLEANUP_CLOSE_LINGER_MS + ) + return () => window.clearTimeout(timer) + }, [open]) + + return { + open, + mountedContent, + loading, + closeModal, + selectedIds, + setSelectedIds, + removal, + startWorkspaceCleanupScan + } +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx new file mode 100644 index 00000000000..413493216ad --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx @@ -0,0 +1,298 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { + createDefaultWorkspaceCleanupFilterState, + DEFAULT_WORKSPACE_CLEANUP_SORT +} from '../../../../shared/workspace-cleanup-filter-model' +import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' +import type * as FacetModule from './workspace-cleanup-facets' +import type * as QueryModule from './workspace-cleanup-query' +import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture' + +const holders = vi.hoisted(() => ({ state: null as AppState | null })) +const counts = vi.hoisted(() => ({ facetCounts: 0, measured: 0, queries: 0 })) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: AppState) => T): T => { + if (!holders.state) { + throw new Error('Missing test state') + } + return selector(holders.state) + } +})) + +vi.mock('./workspace-cleanup-facets', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + countWorkspaceCleanupMeasuredRows: ( + ...args: Parameters + ) => { + counts.measured += 1 + return actual.countWorkspaceCleanupMeasuredRows(...args) + } + } +}) + +vi.mock('./workspace-cleanup-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + countWorkspaceCleanupFacetMatches: ( + ...args: Parameters + ) => { + counts.facetCounts += 1 + return actual.countWorkspaceCleanupFacetMatches(...args) + }, + runWorkspaceCleanupQuery: (...args: Parameters) => { + counts.queries += 1 + return actual.runWorkspaceCleanupQuery(...args) + } + } +}) + +import { useWorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows' + +function makeState(): AppState { + return { + worktreesByRepo: {}, + hostedReviewCache: {}, + repos: [], + settings: {}, + workspaceStatuses: cloneDefaultWorkspaceStatuses(), + lastVisitedAtByWorktreeId: {}, + agentStatusByPaneKey: {}, + tabsByWorktree: {}, + workspaceCleanupDismissals: {}, + workspaceSpaceAnalysis: null, + workspaceSpaceMeasurements: [] + } as unknown as AppState +} + +describe('useWorkspaceCleanupFacetRows hot paths', () => { + beforeEach(() => { + holders.state = makeState() + counts.facetCounts = 0 + counts.measured = 0 + counts.queries = 0 + }) + + it('does only the query work when the user types', () => { + const candidates = [makeFacetCandidate()] + const filters = createDefaultWorkspaceCleanupFilterState() + const view = renderHook( + ({ currentFilters }) => + useWorkspaceCleanupFacetRows({ + candidates, + filters: currentFilters, + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }), + { initialProps: { currentFilters: filters } } + ) + const initialCounts = { ...counts } + const reviewIndex = view.result.current.reviewInfoByWorktreeId + + view.rerender({ currentFilters: { ...filters, query: 'alpha' } }) + + expect(counts.queries).toBeGreaterThan(initialCounts.queries) + expect(counts.facetCounts).toBe(initialCounts.facetCounts) + expect(counts.measured).toBe(initialCounts.measured) + expect(view.result.current.reviewInfoByWorktreeId).toBe(reviewIndex) + }) + + it('keeps review joins stable during unrelated agent-status churn', () => { + const candidates = [makeFacetCandidate()] + const filters = createDefaultWorkspaceCleanupFilterState() + const view = renderHook(() => + useWorkspaceCleanupFacetRows({ + candidates, + filters, + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }) + ) + const reviewIndex = view.result.current.reviewInfoByWorktreeId + + holders.state = { ...holders.state!, agentStatusByPaneKey: {} } + view.rerender() + + expect(view.result.current.reviewInfoByWorktreeId).toBe(reviewIndex) + }) + + it('does not rebuild facets for count-only size progress', () => { + const candidates = [makeFacetCandidate()] + const filters = createDefaultWorkspaceCleanupFilterState() + const view = renderHook(() => + useWorkspaceCleanupFacetRows({ + candidates, + filters, + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }) + ) + const initialCounts = { ...counts } + + holders.state = { + ...holders.state!, + workspaceSpaceScanProgress: { + scanId: 'scan-1', + state: 'running', + startedAt: 1, + updatedAt: 2, + totalRepoCount: 1, + scannedRepoCount: 0, + totalWorktreeCount: 100, + scannedWorktreeCount: 20, + currentRepoDisplayName: 'Repo', + currentWorktreeDisplayName: 'alpha' + } + } + view.rerender() + + expect(counts).toEqual(initialCounts) + }) + + it('projects streamed size measurements before the full scan completes', () => { + const candidates = [makeFacetCandidate()] + const filters = createDefaultWorkspaceCleanupFilterState() + const view = renderHook(() => + useWorkspaceCleanupFacetRows({ + candidates, + filters, + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }) + ) + expect(view.result.current.rows[0]?.sizeBytes).toBeNull() + + holders.state = { + ...holders.state!, + workspaceSpaceMeasurements: [ + { worktreeId: candidates[0]!.worktreeId, status: 'ok', sizeBytes: 4_096 } + ] + } + view.rerender() + + expect(view.result.current.rows[0]?.sizeBytes).toBe(4_096) + expect(view.result.current.measuredSizeCount).toBe(1) + }) + + it('includes folder repositories discovered from cleanup candidates', () => { + const candidate = makeFacetCandidate({ repoId: 'folder-1', repoName: 'Loose files' }) + const view = renderHook(() => + useWorkspaceCleanupFacetRows({ + candidates: [candidate], + filters: createDefaultWorkspaceCleanupFilterState(), + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }) + ) + + expect(view.result.current.options.repos).toEqual([{ id: 'folder-1', label: 'Loose files' }]) + }) + + it('skips every downstream pass when a streaming tick changes no candidate', () => { + const candidates = [makeFacetCandidate(), makeFacetCandidate({ worktreeId: 'repo-1::/b' })] + const filters = createDefaultWorkspaceCleanupFilterState() + const view = renderHook( + ({ current }) => + useWorkspaceCleanupFacetRows({ + candidates: current, + filters, + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }), + { initialProps: { current: candidates } } + ) + const initialCounts = { ...counts } + const matched = view.result.current.facetMatchedWorktreeIds + const rows = view.result.current.rows + + // A no-op progress tick delivers a fresh array of the same candidate objects. + view.rerender({ current: [...candidates] }) + + expect(counts).toEqual(initialCounts) + expect(view.result.current.rows).toBe(rows) + expect(view.result.current.facetMatchedWorktreeIds).toBe(matched) + }) + + it('keeps facet identity for untouched rows when a tick replaces one candidate', () => { + const stable = makeFacetCandidate() + const replaced = makeFacetCandidate({ worktreeId: 'repo-1::/b' }) + const filters = createDefaultWorkspaceCleanupFilterState() + const view = renderHook( + ({ current }) => + useWorkspaceCleanupFacetRows({ + candidates: current, + filters, + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }), + { initialProps: { current: [stable, replaced] } } + ) + const stableFacet = view.result.current.rows.find((row) => row.worktreeId === stable.worktreeId) + + view.rerender({ + current: [stable, makeFacetCandidate({ worktreeId: 'repo-1::/b', displayName: 'renamed' })] + }) + + expect(view.result.current.rows.find((row) => row.worktreeId === stable.worktreeId)).toBe( + stableFacet + ) + expect( + view.result.current.rows.find((row) => row.worktreeId === 'repo-1::/b')?.displayName + ).toBe('renamed') + }) + + it('skips facet count and option passes while the filter panel is closed', () => { + const view = renderHook(() => + useWorkspaceCleanupFacetRows({ + candidates: [makeFacetCandidate()], + filters: createDefaultWorkspaceCleanupFilterState(), + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000, + facetPanelOpen: false + }) + ) + + expect(counts.facetCounts).toBe(0) + expect(view.result.current.options.repos).toEqual([]) + }) + + it('projects same-id streamed sizes only onto their owning hosts', () => { + const worktreeId = 'repo-1::/repo/alpha' + const candidates = [ + makeFacetCandidate({ worktreeId, displayName: 'local', executionHostId: 'local' }), + makeFacetCandidate({ + worktreeId, + displayName: 'remote', + connectionId: 'builder', + executionHostId: 'ssh:builder' + }) + ] + holders.state = { + ...holders.state!, + workspaceSpaceMeasurements: [ + { worktreeId, executionHostId: 'ssh:builder', status: 'ok', sizeBytes: 4_096 } + ] + } + const view = renderHook(() => + useWorkspaceCleanupFacetRows({ + candidates, + filters: createDefaultWorkspaceCleanupFilterState(), + sort: DEFAULT_WORKSPACE_CLEANUP_SORT, + now: 1_700_000_000_000 + }) + ) + + expect( + view.result.current.rows.find((row) => row.displayName === 'local')?.sizeBytes + ).toBeNull() + expect(view.result.current.rows.find((row) => row.displayName === 'remote')?.sizeBytes).toBe( + 4_096 + ) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts new file mode 100644 index 00000000000..b957f715eaa --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts @@ -0,0 +1,289 @@ +import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' +import type { HostedReviewProvider } from '../../../../shared/hosted-review' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { + WorkspaceCleanupFilterState, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' +import { + countWorkspaceCleanupMeasuredRows, + type WorkspaceCleanupFacets +} from './workspace-cleanup-facets' +import { + countWorkspaceCleanupFacetMatches, + filterWorkspaceCleanupFacets, + runWorkspaceCleanupQuery +} from './workspace-cleanup-query' +import { + buildWorkspaceCleanupReviewLookup, + type WorkspaceCleanupReviewInfo +} from './workspace-cleanup-presentation' +import type { + WorkspaceCleanupFacetCounts, + WorkspaceCleanupFacetOptions +} from './workspace-cleanup-facet-panel-model' +import { + buildWorkspaceCleanupSizeIndex, + buildWorkspaceCleanupWorktreeIndex, + countWorkspaceCleanupCandidateIds +} from './workspace-cleanup-host-identity' +import { + computeWorkspaceCleanupFacetList, + computeWorkspaceCleanupReviewInfoIndex, + createWorkspaceCleanupFacetListCache, + createWorkspaceCleanupReviewInfoCache +} from './workspace-cleanup-facet-row-caches' + +export type WorkspaceCleanupFacetRows = { + rows: WorkspaceCleanupFacets[] + selectableWorktreeIds: string[] + facetMatchedWorktreeIds: ReadonlySet + matchedCount: number + totalCount: number + facetCounts: WorkspaceCleanupFacetCounts + options: WorkspaceCleanupFacetOptions + reviewInfoByWorktreeId: ReadonlyMap + sizeByWorktreeId: ReadonlyMap + /** 0 means the workspace-space scan has never produced a usable size. */ + measuredSizeCount: number + unmeasuredSizeCount: number +} + +const EMPTY_FACET_COUNTS: WorkspaceCleanupFacetCounts = Object.freeze({ + activity: 0, + size: 0, + status: 0, + agent: 0, + git: 0, + review: 0, + ticket: 0, + context: 0, + location: 0, + safety: 0 +}) + +const EMPTY_FACET_OPTIONS: WorkspaceCleanupFacetOptions = Object.freeze({ + workspaceStatuses: Object.freeze([]), + hostIds: Object.freeze([]), + repos: Object.freeze([]), + reviewProviders: Object.freeze([]) +}) + +/** + * Joins the scan rows with everything the flat list filters and sorts on: + * renderer-only signals (visits, live agents, review cache, dismissals) plus + * sizes from the EXISTING workspace-space scan — no second scanner. + * + * Per-candidate work is cached on candidate object identity (see + * workspace-cleanup-facet-row-caches) so streaming ticks touch only changed + * rows and no-op ticks skip every downstream pass. + */ +export function useWorkspaceCleanupFacetRows({ + candidates, + filters, + sort, + now, + facetPanelOpen = true +}: { + candidates: readonly WorkspaceCleanupCandidate[] + filters: WorkspaceCleanupFilterState + sort: WorkspaceCleanupSortState + now: number + /** Facet counts/options are only rendered inside the filter popover; pass + * false while it is closed to skip their O(N) passes entirely. */ + facetPanelOpen?: boolean +}): WorkspaceCleanupFacetRows { + const sources = useAppStore( + useShallow((s) => ({ + worktreesByRepo: s.worktreesByRepo, + hostedReviewCache: s.hostedReviewCache, + repos: s.repos, + settings: s.settings, + // Statuses are a top-level UI-slice field, not part of GlobalSettings. + workspaceStatuses: s.workspaceStatuses, + lastVisitedAtByWorktreeId: s.lastVisitedAtByWorktreeId, + agentStatusByPaneKey: s.agentStatusByPaneKey, + tabsByWorktree: s.tabsByWorktree, + dismissals: s.workspaceCleanupDismissals, + spaceWorktrees: s.workspaceSpaceAnalysis?.worktrees ?? null, + spaceMeasurements: s.workspaceSpaceMeasurements + })) + ) + const { hostedReviewCache, repos, settings, worktreesByRepo } = sources + const candidateIdCounts = useMemo( + () => countWorkspaceCleanupCandidateIds(candidates), + [candidates] + ) + // Why: each per-candidate cache lives in one memo together with the derived + // context it is keyed on — the memo deps ARE the cache invalidation, no refs + // (ref writes during render are unsafe under concurrent rendering), and + // interior fills are content-addressed and idempotent. + const reviewContext = useMemo( + () => ({ + sources: { hostedReviewCache, repos, settings, worktreesByRepo }, + lookup: buildWorkspaceCleanupReviewLookup({ repos, worktreesByRepo }), + cache: createWorkspaceCleanupReviewInfoCache() + }), + [hostedReviewCache, repos, settings, worktreesByRepo] + ) + const facetContext = useMemo( + () => ({ + worktreeById: buildWorkspaceCleanupWorktreeIndex(worktreesByRepo, repos), + workspaceStatuses: sources.workspaceStatuses, + cache: createWorkspaceCleanupFacetListCache() + }), + [repos, sources.workspaceStatuses, worktreesByRepo] + ) + const liveAgentStatusByWorktreeId = useMemo( + () => getLiveAgentStatusByWorktreeId(sources.agentStatusByPaneKey, sources.tabsByWorktree, now), + [now, sources.agentStatusByPaneKey, sources.tabsByWorktree] + ) + const dismissedWorktreeIds = useMemo( + () => new Set(Object.keys(sources.dismissals)), + [sources.dismissals] + ) + + const reviewInfoByWorktreeId = useMemo( + () => + computeWorkspaceCleanupReviewInfoIndex({ + candidates, + candidateIdCounts, + reviewSources: reviewContext.sources, + reviewLookup: reviewContext.lookup, + cache: reviewContext.cache + }), + [candidateIdCounts, candidates, reviewContext] + ) + + const completedSizeByWorktreeId = useMemo( + () => buildWorkspaceCleanupSizeIndex(sources.spaceWorktrees, candidates), + [candidates, sources.spaceWorktrees] + ) + const sizeByWorktreeId = useMemo(() => { + if (sources.spaceMeasurements.length === 0) { + return completedSizeByWorktreeId + } + return new Map([ + ...completedSizeByWorktreeId, + ...buildWorkspaceCleanupSizeIndex(sources.spaceMeasurements, candidates) + ]) + }, [candidates, completedSizeByWorktreeId, sources.spaceMeasurements]) + + const facets = useMemo( + () => + computeWorkspaceCleanupFacetList({ + candidates, + sources: { + worktreeById: facetContext.worktreeById, + workspaceStatuses: facetContext.workspaceStatuses, + sizeBytesByWorktreeId: sizeByWorktreeId, + lastVisitedAtByWorktreeId: sources.lastVisitedAtByWorktreeId, + liveAgentStatusByWorktreeId, + reviewInfoByWorktreeId, + dismissedWorktreeIds + }, + cache: facetContext.cache + }), + [ + candidates, + dismissedWorktreeIds, + facetContext, + liveAgentStatusByWorktreeId, + reviewInfoByWorktreeId, + sizeByWorktreeId, + sources.lastVisitedAtByWorktreeId + ] + ) + + const result = useMemo( + () => runWorkspaceCleanupQuery(facets, { filters, sort }, now), + [facets, filters, now, sort] + ) + const facetFilters = useMemo( + () => ({ + query: '', + activity: filters.activity, + size: filters.size, + status: filters.status, + agent: filters.agent, + git: filters.git, + review: filters.review, + ticket: filters.ticket, + context: filters.context, + location: filters.location, + safety: filters.safety + }), + [ + filters.activity, + filters.agent, + filters.context, + filters.git, + filters.location, + filters.review, + filters.safety, + filters.size, + filters.status, + filters.ticket + ] + ) + const facetCounts = useMemo( + () => + facetPanelOpen + ? countWorkspaceCleanupFacetMatches(facets, facetFilters, now) + : EMPTY_FACET_COUNTS, + [facetFilters, facetPanelOpen, facets, now] + ) + // Identity churn here is harmless: the only consumer reads the latest set + // inside a useEffectEvent body and never keys an effect on it. + const facetMatchedWorktreeIds = useMemo>( + () => + new Set(filterWorkspaceCleanupFacets(facets, facetFilters, now).map((row) => row.worktreeId)), + [facetFilters, facets, now] + ) + const measuredSizeCount = useMemo(() => countWorkspaceCleanupMeasuredRows(facets), [facets]) + const unmeasuredSizeCount = facets.length - measuredSizeCount + + const options = useMemo(() => { + if (!facetPanelOpen) { + return EMPTY_FACET_OPTIONS + } + const repoLabels = new Map() + for (const row of facets) { + if (!repoLabels.has(row.repoId)) { + repoLabels.set(row.repoId, row.repoName) + } + } + return { + workspaceStatuses: sources.workspaceStatuses.map((status) => ({ + id: status.id, + label: status.label + })), + hostIds: [...new Set(facets.map((row) => row.hostId))].sort(), + repos: [...repoLabels].map(([id, label]) => ({ id, label })), + reviewProviders: [ + ...new Set( + facets + .map((row) => row.review.provider) + .filter((provider): provider is HostedReviewProvider => provider !== null) + ) + ].sort() + } + }, [facetPanelOpen, facets, sources.workspaceStatuses]) + + return { + rows: result.rows, + selectableWorktreeIds: result.selectableWorktreeIds, + facetMatchedWorktreeIds, + matchedCount: result.matchedCount, + totalCount: result.totalCount, + facetCounts, + options, + reviewInfoByWorktreeId, + sizeByWorktreeId, + measuredSizeCount, + unmeasuredSizeCount + } +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx new file mode 100644 index 00000000000..c4cf958bbcf --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WorkspaceCleanupScanProgress, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' +import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture' +import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evidence' + +const holders = vi.hoisted(() => ({ cancelScan: vi.fn(), scan: vi.fn() })) + +type PendingScan = { + worktreeIds: string[] + onProgress?: (progress: WorkspaceCleanupScanProgress) => void + resolve: (result: WorkspaceCleanupScanResult) => void +} + +function deferredCandidate(worktreeId: string) { + return makeFacetCandidate({ + worktreeId, + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) +} + +describe('useWorkspaceCleanupGitEvidence', () => { + let pending: PendingScan[] + + beforeEach(() => { + pending = [] + holders.scan.mockReset() + holders.cancelScan.mockReset().mockResolvedValue(true) + holders.scan.mockImplementation( + ( + { worktreeIds }: { worktreeIds: string[] }, + onProgress?: (progress: WorkspaceCleanupScanProgress) => void + ) => { + return new Promise((resolve) => { + pending.push({ worktreeIds, onProgress, resolve }) + }) + } + ) + ;(window as unknown as { api: unknown }).api = { + workspaceCleanup: { + cancelScan: holders.cancelScan, + scan: holders.scan + } + } + }) + + it('bounds restarted passes and isolates them from stale settlements', async () => { + const candidates = ['a', 'b', 'c', 'd', 'e', 'f'].map(deferredCandidate) + const view = renderHook( + ({ enabled }: { enabled: boolean }) => + useWorkspaceCleanupGitEvidence({ enabled, candidates, scannedAt: 1 }), + { initialProps: { enabled: true } } + ) + + await waitFor(() => expect(view.result.current.totalCount).toBe(6)) + expect(view.result.current.pendingWorktreeIds.size).toBe(6) + expect(holders.scan).toHaveBeenCalledTimes(1) + expect(holders.scan).toHaveBeenCalledWith( + { + worktreeIds: ['a', 'b', 'c', 'd', 'e', 'f'], + scanId: expect.any(String) + }, + expect.any(Function) + ) + + view.rerender({ enabled: false }) + await waitFor(() => expect(view.result.current.pendingWorktreeIds.size).toBe(0)) + expect(holders.cancelScan).toHaveBeenCalledWith(expect.any(String)) + view.rerender({ enabled: true }) + await waitFor(() => expect(view.result.current.pendingWorktreeIds.size).toBe(6)) + expect(holders.scan).toHaveBeenCalledTimes(1) + expect(view.result.current.pendingWorktreeIds.size).toBe(6) + + await act(async () => { + pending[0]?.resolve({ + scannedAt: 2, + candidates: [makeFacetCandidate({ worktreeId: 'a' })], + errors: [] + }) + }) + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2)) + expect(view.result.current.pendingWorktreeIds.size).toBe(6) + expect(view.result.current.evidenceByWorktreeId.size).toBe(0) + + await act(async () => { + pending[1]?.onProgress?.({ + scanId: 'batch-2', + scannedAt: 3, + scannedWorktreeCount: 1, + totalWorktreeCount: 6, + candidates: [makeFacetCandidate({ worktreeId: 'a' })], + errors: [], + candidateMode: 'append' + }) + }) + await waitFor(() => expect(view.result.current.evidenceByWorktreeId.size).toBe(1)) + expect(view.result.current.pendingWorktreeIds.size).toBe(5) + }) + + it('chunks dispatches at the shared target batch limit so no queued id is dropped', async () => { + const first = Array.from({ length: 300 }, (_, i) => deferredCandidate(`a${i}`)) + const grown = [...first, ...Array.from({ length: 600 }, (_, i) => deferredCandidate(`b${i}`))] + const view = renderHook( + ({ candidates }: { candidates: ReturnType[] }) => + useWorkspaceCleanupGitEvidence({ enabled: true, candidates, scannedAt: 1 }), + { initialProps: { candidates: first } } + ) + + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(1)) + expect(pending[0]?.worktreeIds).toHaveLength(300) + + // 600 more ids queue while the first request is in flight. + view.rerender({ candidates: grown }) + await waitFor(() => expect(view.result.current.totalCount).toBe(900)) + expect(holders.scan).toHaveBeenCalledTimes(1) + + await act(async () => { + pending[0]?.resolve({ scannedAt: 1, candidates: [], errors: [] }) + }) + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2)) + expect(pending[1]?.worktreeIds).toHaveLength(500) + + await act(async () => { + pending[1]?.resolve({ scannedAt: 1, candidates: [], errors: [] }) + }) + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(3)) + expect(pending[2]?.worktreeIds).toHaveLength(100) + + const dispatched = pending.flatMap((request) => request.worktreeIds) + expect(new Set(dispatched).size).toBe(900) + }) + + it('keeps state identity stable when a progress frame changes nothing', async () => { + const candidates = [deferredCandidate('a')] + const view = renderHook(() => + useWorkspaceCleanupGitEvidence({ enabled: true, candidates, scannedAt: 1 }) + ) + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(1)) + const before = view.result.current + + await act(async () => { + pending[0]?.onProgress?.({ + scanId: 'noop', + scannedAt: 1, + scannedWorktreeCount: 0, + totalWorktreeCount: 1, + candidates: [makeFacetCandidate({ worktreeId: 'unrelated' })], + errors: [], + candidateMode: 'append' + }) + }) + expect(view.result.current).toBe(before) + }) + + it('restarts evidence collection when the settled scan snapshot changes', async () => { + const candidates = [deferredCandidate('a')] + const view = renderHook( + ({ scannedAt }: { scannedAt: number }) => + useWorkspaceCleanupGitEvidence({ enabled: true, candidates, scannedAt }), + { initialProps: { scannedAt: 1 } } + ) + + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(1)) + view.rerender({ scannedAt: 2 }) + expect(holders.scan).toHaveBeenCalledTimes(1) + + await act(async () => { + pending[0]?.resolve({ + scannedAt: 1, + candidates: [makeFacetCandidate({ worktreeId: 'a' })], + errors: [] + }) + }) + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2)) + expect(view.result.current.evidenceByWorktreeId.size).toBe(0) + expect(view.result.current.pendingWorktreeIds).toEqual(new Set(['a'])) + + await act(async () => { + pending[1]?.resolve({ + scannedAt: 2, + candidates: [makeFacetCandidate({ worktreeId: 'a' })], + errors: [] + }) + }) + await waitFor(() => expect(view.result.current.evidenceByWorktreeId.size).toBe(1)) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts new file mode 100644 index 00000000000..95377c8a5de --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useMountedRef } from '@/hooks/useMountedRef' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + selectWorkspaceCleanupGitEvidenceTargets, + WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS +} from './workspace-cleanup-git-evidence' + +export type WorkspaceCleanupGitEvidenceState = { + /** Focused re-scan results, keyed by worktree id. */ + evidenceByWorktreeId: ReadonlyMap + pendingWorktreeIds: ReadonlySet + checkedCount: number + totalCount: number +} + +const EMPTY_EVIDENCE: WorkspaceCleanupGitEvidenceState = { + evidenceByWorktreeId: new Map(), + pendingWorktreeIds: new Set(), + checkedCount: 0, + totalCount: 0 +} + +/** + * Fills in git evidence the broad scan deferred with one targeted batch scan. + * Only runs while a git-dependent filter or sort is active — the initial list + * render never waits on it, so an unfiltered browse stays instant. + */ +export function useWorkspaceCleanupGitEvidence({ + enabled, + candidates, + scannedAt +}: { + enabled: boolean + candidates: readonly WorkspaceCleanupCandidate[] + scannedAt: number | null +}): WorkspaceCleanupGitEvidenceState { + const mountedRef = useMountedRef() + const [state, setState] = useState(EMPTY_EVIDENCE) + const evidenceRef = useRef(new Map()) + const attemptedRef = useRef(new Set()) + const queueRef = useRef([]) + const queuedRef = useRef(new Set()) + const inFlightRef = useRef(new Set()) + // Why: restarted filters must share one physical RPC/subprocess cap. + const activeRequestWorktreeIdsRef = useRef(new Set()) + const activeScanIdRef = useRef(null) + const totalRef = useRef(0) + const enabledRef = useRef(enabled) + const scannedAtRef = useRef(scannedAt) + // Why: results that land after the user cleared the git filter belong to a + // stale pass and must not resurrect its pending banner. + const generationRef = useRef(0) + + // Why: streaming ticks call publish repeatedly; unchanged evidence must not + // mint new Map/Set identities or every subscriber re-renders per tick. + const publishDirtyRef = useRef(false) + const publish = useCallback(() => { + if (!mountedRef.current || !publishDirtyRef.current) { + return + } + publishDirtyRef.current = false + setState({ + evidenceByWorktreeId: new Map(evidenceRef.current), + pendingWorktreeIds: new Set([...queuedRef.current, ...inFlightRef.current]), + checkedCount: totalRef.current - queuedRef.current.size - inFlightRef.current.size, + totalCount: totalRef.current + }) + }, [mountedRef]) + + const pump = useCallback(() => { + if (activeRequestWorktreeIdsRef.current.size > 0 || queueRef.current.length === 0) { + publish() + return + } + const generation = generationRef.current + // Why: main truncates targeted scans at this shared limit; a larger request + // would silently drop the overflow ids while marking them attempted. + const worktreeIds = queueRef.current.slice(0, WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS) + queueRef.current = queueRef.current.slice(worktreeIds.length) + const scanId = crypto.randomUUID() + activeScanIdRef.current = scanId + for (const worktreeId of worktreeIds) { + queuedRef.current.delete(worktreeId) + inFlightRef.current.add(worktreeId) + activeRequestWorktreeIdsRef.current.add(worktreeId) + attemptedRef.current.add(worktreeId) + } + publishDirtyRef.current = true + const acceptCandidates = (nextCandidates: readonly WorkspaceCleanupCandidate[]): void => { + if (generation !== generationRef.current) { + return + } + for (const candidate of nextCandidates) { + if (!activeRequestWorktreeIdsRef.current.has(candidate.worktreeId)) { + continue + } + evidenceRef.current.set(candidate.worktreeId, candidate) + inFlightRef.current.delete(candidate.worktreeId) + publishDirtyRef.current = true + } + publish() + } + void window.api.workspaceCleanup + .scan({ worktreeIds, scanId }, (progress) => acceptCandidates(progress.candidates)) + .then((result) => acceptCandidates(result.candidates)) + .catch(() => { + // Why: a failed targeted scan leaves rows honestly "Not checked"; + // attemptedRef keeps it from retrying in a loop. + }) + .finally(() => { + if (activeScanIdRef.current === scanId) { + activeScanIdRef.current = null + } + for (const worktreeId of worktreeIds) { + activeRequestWorktreeIdsRef.current.delete(worktreeId) + if (generation === generationRef.current && inFlightRef.current.delete(worktreeId)) { + publishDirtyRef.current = true + } + } + pump() + }) + publish() + }, [publish]) + + useEffect(() => { + const wasEnabled = enabledRef.current + const snapshotChanged = scannedAtRef.current !== scannedAt + enabledRef.current = enabled + scannedAtRef.current = scannedAt + + if ((wasEnabled && !enabled) || (enabled && snapshotChanged)) { + generationRef.current += 1 + const activeScanId = activeScanIdRef.current + if (activeScanId) { + void window.api.workspaceCleanup.cancelScan?.(activeScanId) + } + queueRef.current = [] + queuedRef.current.clear() + inFlightRef.current.clear() + attemptedRef.current.clear() + evidenceRef.current.clear() + totalRef.current = 0 + publishDirtyRef.current = true + publish() + } + if (!enabled) { + return + } + // Why: the exclusion set must cover queued/in-flight ids too — a cap that + // counts them would permanently skip everything past it on a settled scan. + const targets = selectWorkspaceCleanupGitEvidenceTargets(candidates, { + resolvedWorktreeIds: new Set([ + ...attemptedRef.current, + ...queuedRef.current, + ...inFlightRef.current + ]), + maxTargets: Number.POSITIVE_INFINITY + }) + if (targets.length === 0) { + return + } + queueRef.current.push(...targets) + for (const target of targets) { + queuedRef.current.add(target) + } + totalRef.current += targets.length + publishDirtyRef.current = true + pump() + }, [candidates, enabled, pump, publish, scannedAt]) + + useEffect( + () => () => { + const activeScanId = activeScanIdRef.current + if (activeScanId) { + void window.api.workspaceCleanup.cancelScan?.(activeScanId) + } + }, + [] + ) + + return state +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts new file mode 100644 index 00000000000..2379be86965 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts @@ -0,0 +1,242 @@ +import { useCallback, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { useMountedRef } from '@/hooks/useMountedRef' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + startWorkspaceCleanupBackgroundRemoval, + type WorkspaceCleanupRemovalProgress +} from './workspace-cleanup-background-removal' +import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' + +export type WorkspaceCleanupRemovalController = { + confirming: boolean + confirmCandidates: WorkspaceCleanupCandidate[] + removalProgress: WorkspaceCleanupRemovalProgress | null + removalInFlight: boolean + /** Synchronous guard; state alone lags a second confirm click. */ + removalInFlightRef: { current: boolean } + rowFailures: Record + resetRowFailures: () => void + resetForReopen: () => void + openConfirmRemove: (candidates: readonly WorkspaceCleanupCandidate[]) => void + confirmRemove: () => void + cancelConfirmRemove: () => void + backToList: () => void +} + +/** + * Owns the destructive path: confirm gate, background batch, late settlement, + * and the queued-delete overlay each row shows in the sidebar. + */ +export function useWorkspaceCleanupRemoval({ + onDeselect, + closeModal +}: { + onDeselect: (removedIds: readonly string[]) => void + closeModal: () => void +}): WorkspaceCleanupRemovalController { + const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates) + const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion) + const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) + const mountedRef = useMountedRef() + + const [confirming, setConfirming] = useState(false) + const [confirmCandidates, setConfirmCandidates] = useState([]) + const [removalProgress, setRemovalProgress] = useState( + null + ) + // Why: `removalProgress` only arrives once the batch reports, so rendering + // needs its own in-flight flag; removalInFlightRef stays the synchronous guard. + const [removalInFlight, setRemovalInFlight] = useState(false) + const [rowFailures, setRowFailures] = useState>({}) + const removalInFlightRef = useRef(false) + // Why: the dialog stays mounted across cleanup runs, so late settlements from + // an earlier batch must not mutate a newer batch's row state. + const removalBatchIdRef = useRef(0) + + const resetRowFailures = useCallback(() => setRowFailures({}), []) + + const resetForReopen = useCallback(() => { + if (removalInFlightRef.current) { + return + } + setConfirming(false) + setRowFailures({}) + }, []) + + const clearQueuedDeleteState = useCallback( + (worktreeId: string) => { + const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId] + // Why: candidates that fail before removal starts would otherwise stay + // marked "Queued for deletion" in the sidebar; rows already in the + // 'deleting' phase or failed with an error keep their own state. + if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') { + clearWorktreeDeleteState(worktreeId) + } + }, + [clearWorktreeDeleteState] + ) + + const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { + const nextCandidates = filterWorkspaceCleanupRemovalCandidates( + candidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (nextCandidates.length === 0) { + return + } + setConfirmCandidates(nextCandidates) + setConfirming(true) + }, []) + + const cancelConfirmRemove = useCallback(() => { + if (removalProgress) { + closeModal() + return + } + setConfirming(false) + setConfirmCandidates([]) + }, [closeModal, removalProgress]) + + // Why: the header X reads as "leave this screen", not "abandon the dialog". The + // batch keeps running either way and the list shows each row's progress. + // Diverges from cancelConfirmRemove, which closes the dialog mid-batch: here + // removalProgress stays set until the batch settles, so re-entry stays blocked. + const backToList = useCallback(() => { + setConfirming(false) + setConfirmCandidates([]) + }, []) + + const settle = useCallback(() => { + setRemovalProgress(null) + setRemovalInFlight(false) + setConfirming(false) + setConfirmCandidates([]) + }, []) + + const confirmRemove = useCallback(() => { + if (confirmCandidates.length === 0 || removalInFlightRef.current) { + return + } + const removableCandidates = filterWorkspaceCleanupRemovalCandidates( + confirmCandidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (removableCandidates.length === 0) { + setConfirming(false) + setConfirmCandidates([]) + return + } + removalInFlightRef.current = true + setRemovalInFlight(true) + removalBatchIdRef.current += 1 + const removalBatchId = removalBatchIdRef.current + // Why: a hung late settlement retains these callbacks for the renderer's + // lifetime; capture only ids so it cannot pin the candidate objects. + const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId) + setRowFailures({}) + markWorktreesQueuedForDeletion(removableWorktreeIds) + const handleRemovalError = (): void => { + for (const worktreeId of removableWorktreeIds) { + clearWorktreeDeleteState(worktreeId) + } + if (mountedRef.current) { + settle() + } + removalInFlightRef.current = false + } + try { + const beginSnapshotPruneBatch = window.api.workspaceCleanup.beginRemovalSnapshotPruneBatch + const recordSnapshotPrune = window.api.workspaceCleanup.recordRemovalSnapshotPrune + const finishSnapshotPruneBatch = window.api.workspaceCleanup.finishRemovalSnapshotPruneBatch + const snapshotPruneBatch = + typeof beginSnapshotPruneBatch === 'function' && + typeof recordSnapshotPrune === 'function' && + typeof finishSnapshotPruneBatch === 'function' + ? (() => { + const batchId = crypto.randomUUID() + return { + batchId, + begin: () => beginSnapshotPruneBatch({ batchId }), + finish: () => finishSnapshotPruneBatch({ batchId }) + } + })() + : undefined + startWorkspaceCleanupBackgroundRemoval({ + candidates: removableCandidates, + removeCandidates, + snapshotPruneBatch, + onProgress: (progress) => { + if (mountedRef.current) { + setRemovalProgress(progress) + } + }, + onRowFailed: (failure) => { + clearQueuedDeleteState(failure.worktreeId) + }, + onResult: (result) => { + const nextFailures: Record = {} + for (const failure of result.failures) { + nextFailures[failure.worktreeId] = failure.message + // Why: defensively covers failures that never reached onRowFailed. + clearQueuedDeleteState(failure.worktreeId) + } + if (mountedRef.current) { + setRowFailures(nextFailures) + onDeselect(result.removedIds) + settle() + } + removalInFlightRef.current = false + }, + onLateResult: (result) => { + for (const failure of result.failures) { + // Why: a late failure can come from a hung preflight whose row never + // reached 'deleting'; clear its queued overlay like every other path. + clearQueuedDeleteState(failure.worktreeId) + } + if (!mountedRef.current || removalBatchIdRef.current !== removalBatchId) { + return + } + setRowFailures((current) => { + const next = { ...current } + for (const id of result.removedIds) { + delete next[id] + } + for (const failure of result.failures) { + next[failure.worktreeId] = failure.message + } + return next + }) + onDeselect(result.removedIds) + }, + onError: handleRemovalError + }) + } catch { + handleRemovalError() + } + }, [ + clearQueuedDeleteState, + clearWorktreeDeleteState, + confirmCandidates, + markWorktreesQueuedForDeletion, + mountedRef, + onDeselect, + removeCandidates, + settle + ]) + + return { + confirming, + confirmCandidates, + removalProgress, + removalInFlight, + removalInFlightRef, + rowFailures, + resetRowFailures, + resetForReopen, + openConfirmRemove, + confirmRemove, + cancelConfirmRemove, + backToList + } +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts new file mode 100644 index 00000000000..f2f04547bbc --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { + arrangeWorkspaceCleanupRowsByFrozenOrder, + createWorkspaceCleanupFrozenRowOrder, + extendWorkspaceCleanupFrozenRowOrder, + useWorkspaceCleanupRowOrder +} from './use-workspace-cleanup-row-order' +import type { WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model' + +type Row = { worktreeId: string } + +const row = (worktreeId: string): Row => ({ worktreeId }) +const ids = (rows: readonly Row[]): string[] => rows.map((entry) => entry.worktreeId) +const ascending: WorkspaceCleanupSortState = { field: 'name', direction: 'asc' } + +describe('workspace cleanup streaming row order', () => { + it('keeps existing rows in their frozen slots when streamed values would resort them', () => { + const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b'), row('c')], 'name:asc') + + // The live sort now says c < a < b (values changed mid-stream). + const arranged = arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('a'), row('b')], order) + + expect(ids(arranged)).toEqual(['a', 'b', 'c']) + }) + + it('appends unseen rows after the frozen ones and keeps them stable on later ticks', () => { + const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b')], 'name:asc') + + const firstRows = [row('a'), row('d'), row('b'), row('c')] + const firstTick = arrangeWorkspaceCleanupRowsByFrozenOrder(firstRows, order) + expect(ids(firstTick)).toEqual(['a', 'b', 'd', 'c']) + const extendedOrder = extendWorkspaceCleanupFrozenRowOrder(firstRows, order) + + // A later tick sorting d and c differently must not swap them anymore. + const secondTick = arrangeWorkspaceCleanupRowsByFrozenOrder( + [row('c'), row('d'), row('b'), row('a')], + extendedOrder + ) + expect(ids(secondTick)).toEqual(['a', 'b', 'd', 'c']) + expect(order.positions.has('d')).toBe(false) + }) + + it('holds slots for rows a filter temporarily hides', () => { + const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b'), row('c')], 'name:asc') + + expect(ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('a')], order))).toEqual([ + 'a', + 'c' + ]) + // Un-hiding b restores its original slot between a and c. + expect( + ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('b'), row('a')], order)) + ).toEqual(['a', 'b', 'c']) + }) + + it('grows the frozen order from empty during a cold-start stream', () => { + const order = createWorkspaceCleanupFrozenRowOrder([], 'name:asc') + const firstRows = [row('b'), row('d')] + + expect(ids(arrangeWorkspaceCleanupRowsByFrozenOrder(firstRows, order))).toEqual(['b', 'd']) + const extendedOrder = extendWorkspaceCleanupFrozenRowOrder(firstRows, order) + // New rows slot after the already-presented ones even when they sort earlier. + expect( + ids( + arrangeWorkspaceCleanupRowsByFrozenOrder( + [row('a'), row('b'), row('c'), row('d')], + extendedOrder + ) + ) + ).toEqual(['b', 'd', 'a', 'c']) + }) + + it('commits appended positions after each rendered stream tick', () => { + const view = renderHook( + ({ rows, streaming }: { rows: readonly Row[]; streaming: boolean }) => + useWorkspaceCleanupRowOrder({ rows, streaming, sort: ascending }), + { initialProps: { rows: [row('a'), row('b')], streaming: false } } + ) + + view.rerender({ rows: [row('a'), row('d'), row('b'), row('c')], streaming: true }) + expect(ids(view.result.current)).toEqual(['a', 'b', 'd', 'c']) + + view.rerender({ rows: [row('c'), row('d'), row('b'), row('a')], streaming: true }) + expect(ids(view.result.current)).toEqual(['a', 'b', 'd', 'c']) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.ts new file mode 100644 index 00000000000..ad530fa05ad --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.ts @@ -0,0 +1,123 @@ +import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' +import type { WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model' + +type OrderedRow = { worktreeId: string } + +export type WorkspaceCleanupFrozenRowOrder = { + sortSignature: string + positions: Map +} + +const UNCOMMITTED_ROW_ORDER: WorkspaceCleanupFrozenRowOrder = { + sortSignature: '', + positions: new Map() +} + +export function createWorkspaceCleanupFrozenRowOrder( + rows: readonly OrderedRow[], + sortSignature: string +): WorkspaceCleanupFrozenRowOrder { + return { + sortSignature, + positions: new Map(rows.map((row, index) => [row.worktreeId, index])) + } +} + +/** + * Rows already on screen keep their slot while a refresh streams updated + * values in; rows the order has not seen yet append after them (in the live + * sort among themselves) and claim the next slots, so later ticks cannot + * reshuffle them either. + */ +export function arrangeWorkspaceCleanupRowsByFrozenOrder( + sortedRows: readonly Row[], + order: WorkspaceCleanupFrozenRowOrder +): Row[] { + const known: Row[] = [] + const fresh: Row[] = [] + for (const row of sortedRows) { + if (order.positions.has(row.worktreeId)) { + known.push(row) + } else { + fresh.push(row) + } + } + known.sort( + (left, right) => + (order.positions.get(left.worktreeId) ?? 0) - (order.positions.get(right.worktreeId) ?? 0) + ) + return [...known, ...fresh] +} + +export function extendWorkspaceCleanupFrozenRowOrder( + rows: readonly OrderedRow[], + order: WorkspaceCleanupFrozenRowOrder +): WorkspaceCleanupFrozenRowOrder { + let positions: Map | null = null + for (const row of rows) { + if (order.positions.has(row.worktreeId)) { + continue + } + positions ??= new Map(order.positions) + if (!positions.has(row.worktreeId)) { + positions.set(row.worktreeId, positions.size) + } + } + return positions === null ? order : { ...order, positions } +} + +/** + * Streaming-order stability for the flat list: while a scan streams, existing + * rows must not reshuffle on every progress tick. The user's sort applies to + * the snapshot immediately; streamed value changes land in place; one calm + * re-sort happens when the scan settles — or at once if the user explicitly + * changes the sort mid-scan. + */ +export function useWorkspaceCleanupRowOrder({ + rows, + streaming, + sort +}: { + rows: readonly Row[] + streaming: boolean + sort: WorkspaceCleanupSortState +}): readonly Row[] { + // Why: the frozen order must be React-owned state — a ref read during render + // is not a valid memo input under concurrent rendering and could arrange the + // list from a different render pass's order. The idle capture stays in a ref + // because it is only read and written inside the commit effect. + const [frozenOrder, setFrozenOrder] = + useState(UNCOMMITTED_ROW_ORDER) + const idleOrderRef = useRef(UNCOMMITTED_ROW_ORDER) + const sortSignature = `${sort.field}:${sort.direction}` + const orderedRows = useMemo( + () => + !streaming || frozenOrder.sortSignature !== sortSignature + ? rows + : arrangeWorkspaceCleanupRowsByFrozenOrder(rows, frozenOrder), + [frozenOrder, rows, sortSignature, streaming] + ) + const commitFrozenOrder = useEffectEvent(() => { + // Why: only committed renders may advance the order a later stream holds. + if (!streaming) { + idleOrderRef.current = createWorkspaceCleanupFrozenRowOrder(rows, sortSignature) + setFrozenOrder((current) => + current === UNCOMMITTED_ROW_ORDER ? current : UNCOMMITTED_ROW_ORDER + ) + return + } + setFrozenOrder((current) => { + const base = + current !== UNCOMMITTED_ROW_ORDER && current.sortSignature === sortSignature + ? current + : idleOrderRef.current.sortSignature === sortSignature + ? idleOrderRef.current + : createWorkspaceCleanupFrozenRowOrder(rows, sortSignature) + return extendWorkspaceCleanupFrozenRowOrder(rows, base) + }) + }) + useEffect(() => { + commitFrozenOrder() + }, [rows, sortSignature, streaming]) + return orderedRows +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts new file mode 100644 index 00000000000..8537f2136ff --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useRef } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { formatWorkspaceCleanupReadyToast } from './workspace-cleanup-scan-notice' +import { isWorkspaceCleanupScanSupersededError } from '@/store/slices/workspace-cleanup-broad-scan-registry' + +export type WorkspaceCleanupScanLifecycle = { + startWorkspaceCleanupScan: (options?: { notifyWhenReady?: boolean }) => void +} + +/** + * Owns the dialog's scan cadence: seed the list from the persisted snapshot, + * kick one background rescan per open, and adopt — never duplicate — a scan + * already streaming into the store when the dialog reopens mid-flight. + */ +export function useWorkspaceCleanupScanLifecycle({ + open, + loading, + removalInFlight, + removalInFlightRef, + resetRowFailures, + onFreshOpen +}: { + open: boolean + loading: boolean + removalInFlight: boolean + removalInFlightRef: { current: boolean } + resetRowFailures: () => void + onFreshOpen: () => void +}): WorkspaceCleanupScanLifecycle { + const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup) + const hydrateCleanupFromCache = useAppStore((s) => s.hydrateWorkspaceCleanupFromCache) + const hydrateSpaceFromCache = useAppStore((s) => s.hydrateWorkspaceSpaceFromCache) + const openModal = useAppStore((s) => s.openModal) + const mountedRef = useMountedRef() + const openRef = useRef(open) + const wasOpenRef = useRef(false) + const autoScanAttemptedForOpenRef = useRef(false) + const latestReadyToastScanAtRef = useRef(null) + + useEffect(() => { + openRef.current = open + }, [open]) + + const startWorkspaceCleanupScan = useCallback( + (options: { notifyWhenReady?: boolean } = {}) => { + resetRowFailures() + void scanWorkspaceCleanup() + .then((result) => { + if (!mountedRef.current || !options.notifyWhenReady || openRef.current) { + return + } + if (latestReadyToastScanAtRef.current === result.scannedAt) { + return + } + latestReadyToastScanAtRef.current = result.scannedAt + const suggestedCount = result.candidates.filter( + (candidate) => candidate.selectedByDefault + ).length + toast.success( + translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63', + 'Workspace scan ready' + ), + { + description: formatWorkspaceCleanupReadyToast( + result.candidates.length, + suggestedCount + ), + action: { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764', + 'Review' + ), + onClick: () => openModal('workspace-cleanup') + } + } + ) + }) + .catch((err: unknown) => { + if (mountedRef.current && !isWorkspaceCleanupScanSupersededError(err)) { + toast.error( + translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.662b8ec3f8', + 'Workspace cleanup scan failed' + ), + { description: err instanceof Error ? err.message : String(err) } + ) + } + }) + }, + [mountedRef, openModal, resetRowFailures, scanWorkspaceCleanup] + ) + + const hydrateThenScan = useCallback(() => { + // Why: the cached snapshot must seed the list before the rescan starts so + // progress reconciles into full rows instead of a cold rebuild. + void Promise.allSettled([hydrateCleanupFromCache(), hydrateSpaceFromCache()]).then(() => { + startWorkspaceCleanupScan({ notifyWhenReady: true }) + }) + }, [hydrateCleanupFromCache, hydrateSpaceFromCache, startWorkspaceCleanupScan]) + + useEffect(() => { + if (!open) { + wasOpenRef.current = false + autoScanAttemptedForOpenRef.current = false + return + } + if (!wasOpenRef.current) { + wasOpenRef.current = true + autoScanAttemptedForOpenRef.current = false + onFreshOpen() + } + // Why: reopening mid-batch keeps the deletion progress view; a broad scan + // started here would be discarded by the removal's scan invalidation, so + // skip it while a removal batch is running. + if (removalInFlight || removalInFlightRef.current) { + return + } + if (autoScanAttemptedForOpenRef.current) { + return + } + autoScanAttemptedForOpenRef.current = true + if (loading) { + // Why: reopening mid-scan adopts the stream already feeding the store; + // starting another broad scan here would duplicate it once it settles. + return + } + hydrateThenScan() + }, [hydrateThenScan, loading, onFreshOpen, open, removalInFlight, removalInFlightRef]) + + return { startWorkspaceCleanupScan } +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts new file mode 100644 index 00000000000..1c45ddbc7ad --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import { + hasActiveWorkspaceCleanupFilters, + listActiveWorkspaceCleanupFacetGroups +} from './workspace-cleanup-active-facets' + +describe('listActiveWorkspaceCleanupFacetGroups', () => { + it('reports nothing for the default state', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([]) + expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(false) + }) + + it('names each edited group', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 45 + filters.git.states = ['dirty'] + expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual(['activity', 'git']) + }) + + it('treats multi-select order as meaningless', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.review.states = ['merged', 'closed'] + const reordered = createDefaultWorkspaceCleanupFilterState() + reordered.review.states = ['closed', 'merged'] + expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual( + listActiveWorkspaceCleanupFacetGroups(reordered) + ) + }) + + it('counts a text query as an active filter without naming a group', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.query = ' alpha ' + expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([]) + expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.ts new file mode 100644 index 00000000000..d9b359cff69 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.ts @@ -0,0 +1,70 @@ +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState +} from '../../../../shared/workspace-cleanup-filter-model' + +export type WorkspaceCleanupFacetGroupKey = keyof Omit + +const FACET_GROUP_KEYS: readonly WorkspaceCleanupFacetGroupKey[] = [ + 'activity', + 'size', + 'status', + 'agent', + 'git', + 'review', + 'ticket', + 'context', + 'location', + 'safety' +] + +/** + * Facet groups the user has moved off the default state. Drives the "N filters + * on" badge. + */ +export function listActiveWorkspaceCleanupFacetGroups( + filters: WorkspaceCleanupFilterState +): WorkspaceCleanupFacetGroupKey[] { + const defaults = createDefaultWorkspaceCleanupFilterState() + return FACET_GROUP_KEYS.filter((key) => !isGroupEqual(filters[key], defaults[key])) +} + +export function hasActiveWorkspaceCleanupFilters(filters: WorkspaceCleanupFilterState): boolean { + return ( + filters.query.trim().length > 0 || listActiveWorkspaceCleanupFacetGroups(filters).length > 0 + ) +} + +/** Selection order is not meaning, so array members compare as sets. */ +function isGroupEqual(left: unknown, right: unknown): boolean { + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false + } + const leftMembers = left.map(String).sort() + const rightMembers = right.map(String).sort() + return leftMembers.every((member, index) => member === rightMembers[index]) + } + if (isRecord(left) || isRecord(right)) { + if (!isRecord(left) || !isRecord(right)) { + return false + } + const leftKeys = Object.keys(left).sort() + const rightKeys = Object.keys(right).sort() + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key, index) => key === rightKeys[index] && isGroupEqual(trim(left[key]), trim(right[key])) + ) + ) + } + return left === right +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' +} + +function trim(value: unknown): unknown { + return typeof value === 'string' ? value.trim() : value +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts new file mode 100644 index 00000000000..04b088e43d9 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts @@ -0,0 +1,59 @@ +import { expect, it, vi } from 'vitest' +import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() } +})) + +async function settleBackgroundRemoval(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve() + } +} + +it('holds one snapshot prune batch across the sequential cleanup removals', async () => { + const first = makeCandidate() + const second = makeCandidate({ + worktreeId: 'repo-1::/repo/beta', + displayName: 'beta', + branch: 'beta', + path: '/repo/beta' + }) + const events: string[] = [] + const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => { + events.push(`remove:${worktreeIds[0]}`) + return { removedIds: [...worktreeIds], failures: [] } + }) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [first, second], + removeCandidates, + onProgress: vi.fn(), + snapshotPruneBatch: { + batchId: 'batch-1', + begin: async () => { + events.push('begin') + }, + finish: async () => { + events.push('finish') + } + } + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [first.worktreeId], { + approvedCandidates: [first], + snapshotPruneBatchId: 'batch-1' + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [second.worktreeId], { + approvedCandidates: [second], + snapshotPruneBatchId: 'batch-1' + }) + expect(events).toEqual([ + 'begin', + `remove:${first.worktreeId}`, + `remove:${second.worktreeId}`, + 'finish' + ]) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index 2384e4749b7..9e98435faac 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -50,6 +50,11 @@ export type WorkspaceCleanupBackgroundRemovalArgs = { onRowFailed?: (failure: WorkspaceCleanupFailure) => void removalTimeoutMs?: number removalSettlementGraceMs?: number + snapshotPruneBatch?: { + batchId: string + begin: () => Promise + finish: () => Promise + } } export function startWorkspaceCleanupBackgroundRemoval({ @@ -61,7 +66,8 @@ export function startWorkspaceCleanupBackgroundRemoval({ onError, onRowFailed, removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS, - removalSettlementGraceMs = DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS + removalSettlementGraceMs = DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS, + snapshotPruneBatch }: WorkspaceCleanupBackgroundRemovalArgs): void { if (candidates.length === 0) { try { @@ -166,71 +172,95 @@ export function startWorkspaceCleanupBackgroundRemoval({ } void (async () => { - while (queue.length > 0) { - const candidate = queue.shift() - if (!candidate) { - break - } - const blockers = findBlockingDescendants(candidate) - if (blockers.length > 0) { - skipBlockedAncestor(candidate, blockers) - continue - } + let snapshotPruneBatchActive = false + if (snapshotPruneBatch) { try { - const outcome = await waitForWorkspaceCleanupRemovalWithTimeout( - removeCandidates([candidate.worktreeId], { approvedCandidates: [candidate] }), - removalTimeoutMs, - removalSettlementGraceMs - ) - if (outcome.status === 'rejected') { - throw outcome.error + await snapshotPruneBatch.begin() + snapshotPruneBatchActive = true + } catch (error) { + console.warn('Failed to begin workspace cleanup snapshot prune batch:', error) + } + } + try { + while (queue.length > 0) { + const candidate = queue.shift() + if (!candidate) { + break } - if (outcome.status === 'unresolved') { - const timeoutFailure = getWorkspaceCleanupTimeoutFailure(candidate) - failedCandidates.push(candidate) - provisionallyBlocked.add(candidate) - pendingSettlementFailures.add(timeoutFailure) - reportFailures([timeoutFailure]) - // Why: renderer IPC cannot be cancelled at the timeout boundary; keep - // its authoritative settlement without unblocking ancestors early. - // After the batch ends, detach switches to the post-batch path which - // still holds skip state so ancestors can reclassify. - lateSettlementTrackers.push( - trackWorkspaceCleanupLateSettlement(outcome.settlement, candidate, (lateResult) => { - removeArrayEntry(failures, timeoutFailure) - pendingSettlementFailures.delete(timeoutFailure) - provisionallyBlocked.delete(candidate) - removedIds.push(...lateResult.removedIds) - preservedBranches.push(...(lateResult.preservedBranches ?? [])) - reportFailures(lateResult.failures) - if (lateResult.failures.length === 0) { - removeArrayEntry(failedCandidates, candidate) - } - resettleSkippedAncestors() - emitProgress() - }) - ) + const blockers = findBlockingDescendants(candidate) + if (blockers.length > 0) { + skipBlockedAncestor(candidate, blockers) continue } - const result = outcome.result - removedIds.push(...result.removedIds) - preservedBranches.push(...(result.preservedBranches ?? [])) - reportFailures(result.failures) - if (result.failures.length > 0) { - failedCandidates.push(candidate) - } - } catch (error: unknown) { - failedCandidates.push(candidate) - reportFailures([ - { - worktreeId: candidate.worktreeId, - displayName: candidate.displayName, - message: error instanceof Error ? error.message : String(error) + try { + const outcome = await waitForWorkspaceCleanupRemovalWithTimeout( + removeCandidates([candidate.worktreeId], { + approvedCandidates: [candidate], + ...(snapshotPruneBatchActive && snapshotPruneBatch + ? { snapshotPruneBatchId: snapshotPruneBatch.batchId } + : {}) + }), + removalTimeoutMs, + removalSettlementGraceMs + ) + if (outcome.status === 'rejected') { + throw outcome.error } - ]) - } finally { - processedCount += 1 - emitProgress() + if (outcome.status === 'unresolved') { + const timeoutFailure = getWorkspaceCleanupTimeoutFailure(candidate) + failedCandidates.push(candidate) + provisionallyBlocked.add(candidate) + pendingSettlementFailures.add(timeoutFailure) + reportFailures([timeoutFailure]) + // Why: renderer IPC cannot be cancelled at the timeout boundary; keep + // its authoritative settlement without unblocking ancestors early. + // After the batch ends, detach switches to the post-batch path which + // still holds skip state so ancestors can reclassify. + lateSettlementTrackers.push( + trackWorkspaceCleanupLateSettlement(outcome.settlement, candidate, (lateResult) => { + removeArrayEntry(failures, timeoutFailure) + pendingSettlementFailures.delete(timeoutFailure) + provisionallyBlocked.delete(candidate) + removedIds.push(...lateResult.removedIds) + preservedBranches.push(...(lateResult.preservedBranches ?? [])) + reportFailures(lateResult.failures) + if (lateResult.failures.length === 0) { + removeArrayEntry(failedCandidates, candidate) + } + resettleSkippedAncestors() + emitProgress() + }) + ) + continue + } + const result = outcome.result + removedIds.push(...result.removedIds) + preservedBranches.push(...(result.preservedBranches ?? [])) + reportFailures(result.failures) + if (result.failures.length > 0) { + failedCandidates.push(candidate) + } + } catch (error: unknown) { + failedCandidates.push(candidate) + reportFailures([ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: error instanceof Error ? error.message : String(error) + } + ]) + } finally { + processedCount += 1 + emitProgress() + } + } + } finally { + if (snapshotPruneBatchActive && snapshotPruneBatch) { + try { + await snapshotPruneBatch.finish() + } catch (error) { + console.warn('Failed to finish workspace cleanup snapshot prune batch:', error) + } } } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx new file mode 100644 index 00000000000..c70209dd0eb --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx @@ -0,0 +1,96 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupBrowseController } from './use-workspace-cleanup-browse-state' +import type { WorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows' +import { listActiveWorkspaceCleanupFacetGroups } from './workspace-cleanup-active-facets' +import { + WorkspaceCleanupEmptyState, + WorkspaceCleanupSizeScanBanner +} from './workspace-cleanup-dialog-notices' +import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar' +import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header' +import type { WorkspaceSpaceScanProgress } from '../../../../shared/workspace-space-types' + +export function WorkspaceCleanupBrowseToolbar({ + browse, + facetRows, + facetPanelOpen, + onFacetPanelOpenChange, + selectableCount, + selectedCount, + spaceScanning, + spaceProgress, + gitPendingCount, + gitCheckedTotal, + onRunSpaceScan, + onToggleSelectAll +}: { + browse: WorkspaceCleanupBrowseController + facetRows: WorkspaceCleanupFacetRows + facetPanelOpen: boolean + onFacetPanelOpenChange: (open: boolean) => void + selectableCount: number + selectedCount: number + spaceScanning: boolean + spaceProgress: WorkspaceSpaceScanProgress | null + gitPendingCount: number + gitCheckedTotal: number + onRunSpaceScan: () => void + onToggleSelectAll: (selectAll: boolean) => void +}): React.JSX.Element { + // Why: this toolbar re-renders per keystroke and per streaming tick; the + // group comparisons must not rerun unless the filter state itself changed. + const activeFacetGroupCount = React.useMemo( + () => listActiveWorkspaceCleanupFacetGroups(browse.filters).length, + [browse.filters] + ) + const activeFilters = browse.filters.query.trim().length > 0 || activeFacetGroupCount > 0 + return ( + <> + {spaceScanning || facetRows.unmeasuredSizeCount > 0 ? ( + + ) : null} + browse.patchFilters('query', query)} + onClearFilters={browse.clearFilters} + /> + + {browse.sort.field === 'size' && facetRows.measuredSizeCount === 0 && !spaceScanning ? ( + + ) : null} + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx index 68569c7aba9..7cfd365bc60 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx @@ -1,6 +1,8 @@ import React from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' + +/** Any row shape the flat list renders — raw candidates or enriched facet rows. */ +type WorkspaceCleanupListRow = { worktreeId: string } // Why: below this count plain rows keep the pre-virtualization DOM (natural // flow, no absolute positioning), so the common few-worktrees case is @@ -19,16 +21,18 @@ const WORKSPACE_CLEANUP_ROW_OVERSCAN = 8 * no scroll-margin bookkeeping is needed. Lists shorter than * WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS render plainly. */ -export function WorkspaceCleanupCandidateList({ +export function WorkspaceCleanupCandidateList({ rows, renderRow, + estimatedRowHeight = WORKSPACE_CLEANUP_ROW_ESTIMATE_PX, // Why: a state-held element, not a ref — the ScrollArea viewport is not // attached when this component first mounts, so a ref would leave the // virtualizer unobserved until some unrelated re-render. scrollElement }: { - rows: readonly WorkspaceCleanupCandidate[] - renderRow: (candidate: WorkspaceCleanupCandidate, index: number) => React.ReactNode + rows: readonly Row[] + renderRow: (row: Row, index: number) => React.ReactNode + estimatedRowHeight?: number scrollElement: HTMLDivElement | null }): React.JSX.Element { const virtualize = rows.length >= WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS @@ -37,7 +41,7 @@ export function WorkspaceCleanupCandidateList({ count: rows.length, enabled: virtualize && scrollElement !== null, getScrollElement: () => scrollElement, - estimateSize: () => WORKSPACE_CLEANUP_ROW_ESTIMATE_PX, + estimateSize: () => estimatedRowHeight, overscan: WORKSPACE_CLEANUP_ROW_OVERSCAN, // Why: stable worktree keys let the virtualizer carry row identity across // scan refreshes instead of remounting the window on every streamed row. diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts index 3cd5309ac54..7ed376b10b6 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts @@ -1,8 +1,20 @@ import { describe, expect, it } from 'vitest' -import { getDirtyGitLabel, shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' +import { + getCandidateFactStatus, + getDirtyGitLabel, + shouldShowGitMetadataChip +} from './workspace-cleanup-candidate-row-data' import { makeCandidate } from './workspace-cleanup-presentation-fixtures' describe('workspace cleanup candidate row data', () => { + it('shows facts instead of cleanup policy tiers', () => { + expect(getCandidateFactStatus(makeCandidate({ tier: 'ready' }))).toBeNull() + expect(getCandidateFactStatus(makeCandidate({ tier: 'review' }))).toBeNull() + expect( + getCandidateFactStatus(makeCandidate({ tier: 'ready', reasons: ['archived'] })) + ).toMatchObject({ label: 'Archived' }) + }) + it('does not duplicate git status blockers as a separate git icon label', () => { const gitStatusError = makeCandidate({ blockers: ['git-status-error'], @@ -29,6 +41,18 @@ describe('workspace cleanup candidate row data', () => { ).toBe(true) }) + it('keeps unpushed risk visible for archived rows', () => { + expect( + getCandidateFactStatus( + makeCandidate({ + tier: 'review', + reasons: ['archived'], + git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toMatchObject({ label: 'Unpushed commits' }) + }) + it('suppresses the git metadata chip when the status pill already names git risk', () => { expect( shouldShowGitMetadataChip( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts index 9e819d90017..42a7d0c58a5 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -23,10 +23,10 @@ export function getWorkspaceCleanupBlockerLabels(candidate: WorkspaceCleanupCand return candidate.blockers.map((blocker) => getWorkspaceCleanupBlockerLabel(blocker)) } -export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { +export function getCandidateFactStatus(candidate: WorkspaceCleanupCandidate): { label: string tone: StatusPillTone -} { +} | null { if (candidate.blockers.includes('dismissed')) { return { label: translate( @@ -36,20 +36,6 @@ export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { tone: 'neutral' } } - if (candidate.tier === 'ready') { - return { - label: candidate.reasons.includes('archived') - ? translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', - 'Archived' - ) - : translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.readyStatus', - 'Ready' - ), - tone: 'ready' - } - } if (candidate.blockers.length > 0) { return { label: getWorkspaceCleanupBlockerLabel(candidate.blockers[0]), tone: 'neutral' } } @@ -71,22 +57,16 @@ export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { tone: 'review' } } - if (candidate.tier === 'review') { + if (candidate.reasons.includes('archived')) { return { label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0a2e3c7cba', - 'Review' + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', + 'Archived' ), - tone: 'review' + tone: 'neutral' } } - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4f4782c02', - 'Not suggested' - ), - tone: 'neutral' - } + return null } export function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx index 7de51d662cb..2f8ecc9fb07 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx @@ -31,6 +31,103 @@ describe('CandidateRow', () => { container = null }) + it('does not present cleanup policy tiers as workspace facts', () => { + const candidate = makeCandidate({ tier: 'ready' }) + + act(() => { + root?.render( + + ) + }) + + expect(container?.textContent).not.toContain('Ready') + expect(container?.textContent).not.toContain('Status unavailable') + }) + + it('shows factual status, disk size, activity, and git state before expansion', () => { + const candidate = makeCandidate({ tier: 'ready' }) + + act(() => { + root?.render( + + ) + }) + + expect(container?.textContent).toContain('In progress') + expect(container?.textContent).toContain('4.00 MB') + expect(container?.textContent).toContain('1d') + expect(container?.textContent).toContain('Clean') + }) + + it('uses an external-link action to open the workspace', () => { + const candidate = makeCandidate() + + act(() => { + root?.render( + + ) + }) + + const openButton = container?.querySelector(`[aria-label="Open ${candidate.displayName}"]`) + expect(openButton?.querySelector('.lucide-external-link')).not.toBeNull() + }) + it('hides selection and remove controls while the workspace is already deleting', () => { const candidate = makeCandidate() diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index 2953f46fa5a..f8f68b03a9c 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -3,16 +3,17 @@ import { AlertTriangle, Check, ChevronDown, + CircleDot, Clock3, EyeOff, + ExternalLink, FileWarning, GitBranch, GitPullRequest, + HardDrive, Loader2, - Search, SquareTerminal, - Trash2, - type LucideIcon + Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -31,15 +32,15 @@ import { formatBranchSafetyDetails, formatContextDetails, formatGitStatus, - getCandidateStatus, + getCandidateFactStatus, getContextCount, getDirtyGitLabel, getReviewPillTone, getWorkspaceCleanupBlockerLabels, - shouldShowGitMetadataChip, - type StatusPillTone + shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' import { StatusPill } from './workspace-cleanup-status-pill' +import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip' export type WorkspaceCleanupDeletionPhase = 'deleting' | 'queued' @@ -48,9 +49,15 @@ type CandidateRowProps = { deletionPhase?: WorkspaceCleanupDeletionPhase expanded: boolean failure?: string + /** A focused git re-scan is in flight, so "Not checked" is provisional. */ + gitEvidencePending?: boolean last: boolean lastActivityLabel: string removing?: boolean + /** Joined from the workspace-space scan; null when that scan has not run. */ + sizeLabel?: string | null + /** User-configured status label; null when this broad-scan row is not in renderer state. */ + workspaceStatusLabel?: string | null reviewInfo: WorkspaceCleanupReviewInfo selected: boolean onIgnore: (candidate: WorkspaceCleanupCandidate) => void @@ -60,42 +67,6 @@ type CandidateRowProps = { onView: (candidate: WorkspaceCleanupCandidate) => void } -function MetadataIconChip({ - icon: Icon, - label, - value, - tone = 'neutral' -}: { - icon: LucideIcon - label: string - value?: string - tone?: StatusPillTone -}): React.JSX.Element { - return ( - - - - - - - {label} - - - ) -} - // Why: the cleanup list re-renders on every checkbox/expand/search keystroke; // memo keeps each unchanged row from re-rendering. Effective only while the // parent passes stable (useCallback) handlers — see WorkspaceCleanupDialog. @@ -106,9 +77,12 @@ export const CandidateRow = React.memo(function CandidateRow({ deletionPhase, expanded, failure, + gitEvidencePending = false, last, lastActivityLabel, removing = false, + sizeLabel = null, + workspaceStatusLabel = null, reviewInfo, selected, onIgnore, @@ -125,10 +99,13 @@ export const CandidateRow = React.memo(function CandidateRow({ const blockers = getWorkspaceCleanupBlockerLabels(candidate) const contextDetails = formatContextDetails(candidate) const branchSafetyDetails = formatBranchSafetyDetails(candidate) - const status = getCandidateStatus(candidate) + const factStatus = getCandidateFactStatus(candidate) const dirtyLabel = getDirtyGitLabel(candidate) + const gitLabel = getWorkspaceCleanupGitLabel(candidate) const showGitMetadataChip = shouldShowGitMetadataChip(candidate) const contextCount = getContextCount(candidate) + const sizeValue = + sizeLabel ?? translate('components.workspace.cleanup.browse.notMeasured', 'Not measured') const hasExpandableDetails = blockers.length > 0 || candidate.path.length > 0 || @@ -169,7 +146,9 @@ export const CandidateRow = React.memo(function CandidateRow({
- {candidate.displayName} + + {candidate.displayName} + {deletionPhase ? ( {deletionPhase === 'queued' @@ -182,10 +161,21 @@ export const CandidateRow = React.memo(function CandidateRow({ 'Deleting…' )} - ) : ( - {status.label} - )} - {factStatus.label} + ) : null} + {workspaceStatusLabel ? ( + + ) : null} + - {dirtyLabel && showGitMetadataChip ? ( - + + {gitEvidencePending ? ( + + + ) : dirtyLabel && showGitMetadataChip ? ( + ) : showGitMetadataChip ? ( - ) : null} {contextDetails ? ( - ) : null} {reviewInfo.label ? ( - onView(candidate)} > - + - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ee81adfcef', - 'View' - )} + {translate('components.workspace.cleanup.browse.openWorkspace', 'Open workspace')} {!ignored ? ( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx new file mode 100644 index 00000000000..f93672103c8 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture' +import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove' + +const listProbe = vi.hoisted(() => vi.fn()) + +vi.mock('./workspace-cleanup-candidate-list', () => ({ + WorkspaceCleanupCandidateList: ({ + rows, + renderRow + }: { + rows: readonly Row[] + renderRow: (row: Row, index: number) => ReactNode + }) => { + listProbe(rows.length) + return <>{rows.slice(0, 5).map(renderRow)} + } +})) + +vi.mock('@/components/ui/dialog', () => ({ + DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
+})) + +let container: HTMLDivElement +let root: Root + +describe('WorkspaceCleanupConfirmRemove', () => { + beforeEach(() => { + listProbe.mockClear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() + }) + + it('routes fleet-sized confirmations through the bounded row list', () => { + const candidates = Array.from({ length: 2_727 }, (_, index) => + makeFacetCandidate({ + worktreeId: `repo-1::/repo/worktree-${index}`, + displayName: `Workspace ${index}`, + path: `/repo/worktree-${index}` + }) + ) + + act(() => { + root.render( + + ) + }) + + expect(listProbe).toHaveBeenCalledWith(candidates.length) + expect(container.querySelectorAll('span.truncate.text-sm.font-medium')).toHaveLength(5) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx new file mode 100644 index 00000000000..0d946cc14e0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx @@ -0,0 +1,253 @@ +import React from 'react' +import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Progress } from '@/components/ui/progress' +import { ScrollArea } from '@/components/ui/scroll-area' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' +import { + getCandidateFactStatus, + getContextPillLabel, + getDirtyGitLabel, + getReviewPillTone, + shouldShowGitMetadataChip +} from './workspace-cleanup-candidate-row-data' +import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' +import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' +import { StatusPill } from './workspace-cleanup-status-pill' +import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' +import { + getWorkspaceCleanupCandidateHostId, + getWorkspaceCleanupHostIdentity +} from './workspace-cleanup-host-identity' + +const CONFIRM_REMOVE_ROW_ESTIMATE_PX = 76 + +const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = { + hasReview: false, + label: null, + state: null, + provider: null, + title: null +} + +export function WorkspaceCleanupConfirmRemove({ + candidates, + now, + reviewInfoByWorktreeId, + progress, + onBack, + onCancel, + onConfirm +}: { + candidates: WorkspaceCleanupCandidate[] + /** The dialog's "as of" clock; keeps labels consistent with the list view. */ + now: number + reviewInfoByWorktreeId: ReadonlyMap + progress: WorkspaceCleanupRemovalProgress | null + onBack: () => void + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + const [scrollElement, setScrollElement] = React.useState(null) + const count = candidates.length + const deleting = progress !== null + const progressValue = progress + ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) + : 0 + return ( + <> + +
+
+
+ {deleting ? ( + + ) : ( + + )} +
+
+ + {deleting + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deletingCount', + 'Deleting workspaces: {{value0}}', + { value0: count } + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteCount', + 'Delete workspaces: {{value0}}?', + { value0: count } + )} + + + {deleting + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1d3503357d', + 'You can close this and come back while deletion continues.' + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400', + "This permanently deletes their local files. You can't undo this." + )} + +
+
+ +
+
+
+ {progress ? ( +
+
+ + + {formatWorkspaceCleanupRemovalProgress(progress)} + +
+ +
+ ) : null} +
+
+ {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.selectedForDeletionCount', + 'Selected for deletion: {{value0}}', + { value0: count } + )} +
+
+ + ( + + )} + /> + +
+ + + {!deleting ? ( + + ) : null} + + + ) +} + +function ConfirmRemoveRow({ + candidate, + now, + reviewInfo, + last +}: { + candidate: WorkspaceCleanupCandidate + now: number + reviewInfo: WorkspaceCleanupReviewInfo + last: boolean +}): React.JSX.Element { + const dirtyLabel = getDirtyGitLabel(candidate) + const branchDiffersFromName = candidate.branch !== candidate.displayName + const contextPillLabel = getContextPillLabel(candidate) + const showGitMetadataChip = shouldShowGitMetadataChip(candidate) + const factStatus = getCandidateFactStatus(candidate) + return ( +
+
+ {candidate.displayName} + + {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc', + 'Last active' + )}{' '} + {formatWorkspaceCleanupRelativeTime(candidate.lastActivityAt, now)} + + {factStatus ? {factStatus.label} : null} + {reviewInfo.label ? ( + {reviewInfo.label} + ) : null} + {contextPillLabel ? {contextPillLabel} : null} + {dirtyLabel && showGitMetadataChip ? ( + {dirtyLabel} + ) : null} +
+
+ {candidate.repoName} + {branchDiffersFromName ? ( + <> + + {candidate.branch} + + ) : null} +
+
+ {candidate.path} +
+
+ ) +} + +function formatWorkspaceCleanupRemovalProgress(progress: WorkspaceCleanupRemovalProgress): string { + const deletedText = translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4c2990886e', + '{{value0}}/{{value1}} deleted', + { value0: progress.removedCount, value1: progress.totalCount } + ) + if (progress.failedCount === 0) { + return deletedText + } + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.86ba852118', + '{{value0}}, {{value1}} failed', + { value0: deletedText, value1: progress.failedCount } + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-header.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-header.tsx new file mode 100644 index 00000000000..a632903a8cc --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-header.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import { RefreshCcw, Trash2, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup' +import { WorkspaceCleanupFreshness } from './workspace-cleanup-freshness' + +export function WorkspaceCleanupDialogHeader({ + selectedCount, + deleteDisabled, + loading, + scannedAt, + scanProgress, + onDeleteSelected, + onRefresh, + onClose +}: { + selectedCount: number + deleteDisabled: boolean + loading: boolean + scannedAt: number | null + scanProgress: WorkspaceCleanupScanProgress | null + onDeleteSelected: () => void + onRefresh: () => void + onClose: () => void +}): React.JSX.Element { + const refreshLabel = translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7ae2ad30f4', + 'Refresh' + ) + return ( + +
+ + {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.b2c1331844', + 'Delete Inactive Workspaces' + )} + +
+ + + {translate('components.workspace.cleanup.browse.selectedCount', '{{value0}} selected', { + value0: selectedCount + })} + + + + + + + + {refreshLabel} + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx new file mode 100644 index 00000000000..8544d8eb415 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + WorkspaceCleanupInitialScanBanner, + WorkspaceCleanupSizeScanBanner +} from './workspace-cleanup-dialog-notices' + +afterEach(cleanup) + +describe('WorkspaceCleanupInitialScanBanner', () => { + it('puts determinate progress in the title with matching typography', () => { + render( + + ) + + expect(screen.getByText('Scanning workspaces (23/3048)')).toBeTruthy() + expect(screen.queryByText('Checked workspaces so far: 23')).toBeNull() + }) +}) + +describe('WorkspaceCleanupSizeScanBanner', () => { + it('explains why scanning is useful and exposes the action', () => { + render( + + ) + + expect(screen.getByText('Scan workspace sizes')).toBeTruthy() + expect( + screen.getByText('Scan disk usage to compare, sort, and filter workspaces by size.') + ).toBeTruthy() + expect(screen.getByRole('button', { name: 'Scan' })).toBeTruthy() + }) + + it('shows determinate progress and disables duplicate runs', () => { + render( + + ) + + expect( + (screen.getByRole('button', { name: 'Scanning 23/100' }) as HTMLButtonElement).disabled + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx new file mode 100644 index 00000000000..40592de7f5b --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx @@ -0,0 +1,148 @@ +import React from 'react' +import { AlertTriangle, HardDrive, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup' + +export function WorkspaceCleanupInitialScanBanner({ + progress +}: { + progress: WorkspaceCleanupScanProgress | null +}): React.JSX.Element { + const title = + progress && progress.totalWorktreeCount > 0 + ? translate( + 'components.workspace.cleanup.scan.progress', + 'Scanning workspaces ({{value0}}/{{value1}})', + { + value0: progress.scannedWorktreeCount, + value1: progress.totalWorktreeCount + } + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7eee951968', + 'Scanning workspaces' + ) + + return ( +
+ +
+
{title}
+
+ {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.47123d0108', + 'Collecting workspace information. You can close this and come back.' + )} +
+
+
+ ) +} + +export function WorkspaceCleanupSizeScanBanner({ + scanning, + scannedCount, + totalCount, + onRun +}: { + scanning: boolean + scannedCount: number + totalCount: number + onRun: () => void +}): React.JSX.Element { + const actionLabel = scanning + ? totalCount > 0 + ? translate( + 'components.workspace.cleanup.browse.measuringSizesProgress', + 'Scanning {{value0}}/{{value1}}', + { value0: scannedCount, value1: totalCount } + ) + : translate('components.workspace.cleanup.browse.measuringSizes', 'Scanning sizes') + : translate('components.workspace.cleanup.browse.measureSizes', 'Scan') + + return ( +
+
+ +
+
+ {translate( + 'components.workspace.cleanup.browse.measureSizesTitle', + 'Scan workspace sizes' + )} +
+
+ {translate( + 'components.workspace.cleanup.browse.measureSizesDescription', + 'Scan disk usage to compare, sort, and filter workspaces by size.' + )} +
+
+
+ +
+ ) +} + +export function WorkspaceCleanupNotice({ + tone = 'muted', + message +}: { + tone?: 'muted' | 'destructive' + message: string +}): React.JSX.Element { + if (tone === 'destructive') { + return ( +
+ {message} +
+ ) + } + return ( +
+ + {message} +
+ ) +} + +export function WorkspaceCleanupEmptyState({ + title, + description, + actionLabel, + onAction +}: { + title: string + description?: string + actionLabel?: string + onAction?: () => void +}): React.JSX.Element { + return ( +
+ {title} + {description ? {description} : null} + {actionLabel && onAction ? ( + + ) : null} +
+ ) +} + +export function WorkspaceCleanupSkeletonRows(): React.JSX.Element { + return ( +
+ {[0, 1, 2].map((index) => ( +
+ ))} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.test.tsx deleted file mode 100644 index ae35cbc9b6d..00000000000 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -// @vitest-environment happy-dom - -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' -import { useAppStore } from '@/store' -import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup' -import { - useWorkspaceCleanupDialogSession, - type WorkspaceCleanupDialogSession -} from './workspace-cleanup-dialog-session' -import { makeCandidate } from './workspace-cleanup-presentation-fixtures' - -const toastMocks = vi.hoisted(() => ({ - error: vi.fn(), - info: vi.fn(), - success: vi.fn() -})) - -vi.mock('sonner', () => ({ toast: toastMocks })) - -const initialAppState = useAppStore.getInitialState() -let container: HTMLDivElement -let root: Root -let currentSession: WorkspaceCleanupDialogSession | null - -function deferred(): { - promise: Promise - resolve: (value: T) => void -} { - let resolve!: (value: T) => void - const promise = new Promise((innerResolve) => { - resolve = innerResolve - }) - return { promise, resolve } -} - -function Probe(): null { - currentSession = useWorkspaceCleanupDialogSession() - return null -} - -function session(): WorkspaceCleanupDialogSession { - if (!currentSession) { - throw new Error('Workspace cleanup session probe is not mounted') - } - return currentSession -} - -async function renderProbe(): Promise { - await act(async () => root.render()) -} - -describe('useWorkspaceCleanupDialogSession', () => { - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - vi.clearAllMocks() - useAppStore.setState(initialAppState, true) - useAppStore.setState({ - activeModal: 'workspace-cleanup', - workspaceCleanupLoading: false, - deleteStateByWorktreeId: {} - }) - currentSession = null - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - }) - - afterEach(async () => { - await act(async () => root.unmount()) - container.remove() - currentSession = null - useAppStore.setState(initialAppState, true) - }) - - it('offers to reopen when a scan finishes after the dialog closes', async () => { - const candidate = makeCandidate() - const scan = deferred() - const scanWorkspaceCleanup = vi.fn(() => scan.promise) - useAppStore.setState({ scanWorkspaceCleanup }) - - await renderProbe() - expect(scanWorkspaceCleanup).toHaveBeenCalledOnce() - - await act(async () => useAppStore.getState().closeModal()) - expect(session().open).toBe(false) - - await act(async () => { - scan.resolve({ scannedAt: 42, candidates: [candidate], errors: [] }) - await scan.promise - }) - - expect(toastMocks.success).toHaveBeenCalledWith( - 'Inactive workspace scan ready', - expect.objectContaining({ - description: '1 inactive workspace found, with 1 cleanup suggestion.', - action: expect.objectContaining({ label: 'Review' }) - }) - ) - const options = toastMocks.success.mock.lastCall?.[1] as { - action?: { onClick: () => void } - } - - await act(async () => options.action?.onClick()) - - expect(useAppStore.getState().activeModal).toBe('workspace-cleanup') - expect(session().open).toBe(true) - }) - - it('keeps a deferred removal alive and visible across close and reopen', async () => { - const candidate = makeCandidate() - const removal = deferred() - const removeWorkspaceCleanupCandidates = vi.fn(() => removal.promise) - const scanWorkspaceCleanup = vi.fn().mockResolvedValue({ - scannedAt: 41, - candidates: [candidate], - errors: [] - } satisfies WorkspaceCleanupScanResult) - useAppStore.setState({ removeWorkspaceCleanupCandidates, scanWorkspaceCleanup }) - await renderProbe() - - await act(async () => session().openConfirmRemove([candidate])) - await act(async () => session().confirmRemove()) - - expect(removeWorkspaceCleanupCandidates).toHaveBeenCalledWith([candidate.worktreeId], { - approvedCandidates: [candidate] - }) - expect(session()).toMatchObject({ - open: true, - confirming: true, - removalInFlight: true, - removalProgress: { - totalCount: 1, - processedCount: 0, - removedCount: 0, - failedCount: 0 - } - }) - - await act(async () => session().close()) - - expect(session()).toMatchObject({ - open: false, - confirming: true, - removalInFlight: true - }) - expect(session().removalProgress?.processedCount).toBe(0) - - await act(async () => useAppStore.getState().openModal('workspace-cleanup')) - - expect(session()).toMatchObject({ - open: true, - confirming: true, - removalInFlight: true - }) - expect(session().removalProgress?.processedCount).toBe(0) - expect(scanWorkspaceCleanup).toHaveBeenCalledOnce() - - await act(async () => { - removal.resolve({ removedIds: [candidate.worktreeId], failures: [] }) - await removal.promise - await Promise.resolve() - }) - - expect(session()).toMatchObject({ - open: true, - confirming: false, - confirmCandidates: [], - removalInFlight: false, - removalProgress: null - }) - }) -}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.ts deleted file mode 100644 index 670e73fa4dc..00000000000 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { - useCallback, - useEffect, - useRef, - useState, - type Dispatch, - type RefObject, - type SetStateAction -} from 'react' -import { toast } from 'sonner' -import { useShallow } from 'zustand/react/shallow' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' -import { useMountedRef } from '@/hooks/useMountedRef' -import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' -import { - useWorkspaceCleanupRemovalSession, - type WorkspaceCleanupRemovalSession -} from './workspace-cleanup-removal-session' -import { useWorkspaceCleanupScanSession } from './workspace-cleanup-scan-session' -import type { - WorkspaceCleanupFilters, - WorkspaceCleanupSortDirection, - WorkspaceCleanupSortKey -} from './workspace-cleanup-presentation' -import type { WorkspaceCleanupView } from './workspace-cleanup-view-selection' - -export const DEFAULT_WORKSPACE_CLEANUP_FILTERS: WorkspaceCleanupFilters = { - query: '', - time: 'all', - review: 'all', - git: 'all', - context: 'all' -} - -type WorkspaceCleanupStateSetter = Dispatch> -type PublicRemovalSession = Omit< - WorkspaceCleanupRemovalSession, - 'clearRowFailures' | 'resetForOpen' -> - -export type WorkspaceCleanupDialogSession = PublicRemovalSession & { - open: boolean - loading: boolean - selectedIds: Set - setSelectedIds: WorkspaceCleanupStateSetter> - expandedRowIds: Set - setExpandedRowIds: WorkspaceCleanupStateSetter> - activeView: WorkspaceCleanupView - setActiveView: WorkspaceCleanupStateSetter - repoSelection: ReadonlySet - setRepoSelection: WorkspaceCleanupStateSetter> - filters: WorkspaceCleanupFilters - setFilters: WorkspaceCleanupStateSetter - sortKey: WorkspaceCleanupSortKey - setSortKey: WorkspaceCleanupStateSetter - sortDirection: WorkspaceCleanupSortDirection - setSortDirection: WorkspaceCleanupStateSetter - selectedDefaultsScanAtRef: RefObject - close: () => void - markCandidateViewed: (candidate: WorkspaceCleanupCandidate) => void - restoreDismissals: () => void - startScan: (options?: { notifyWhenReady?: boolean }) => void - ignoreCandidate: (candidate: WorkspaceCleanupCandidate) => void -} - -export function useWorkspaceCleanupDialogSession(): WorkspaceCleanupDialogSession { - const { - open, - loading, - openModal, - closeModal, - scanWorkspaceCleanup, - markCandidateViewed, - dismissCandidates, - resetDismissals, - removeCandidates, - markWorktreesQueuedForDeletion, - clearWorktreeDeleteState - } = useAppStore( - useShallow((state) => ({ - open: state.activeModal === 'workspace-cleanup', - loading: state.workspaceCleanupLoading, - openModal: state.openModal, - closeModal: state.closeModal, - scanWorkspaceCleanup: state.scanWorkspaceCleanup, - markCandidateViewed: state.markWorkspaceCleanupCandidateViewed, - dismissCandidates: state.dismissWorkspaceCleanupCandidates, - resetDismissals: state.resetWorkspaceCleanupDismissals, - removeCandidates: state.removeWorkspaceCleanupCandidates, - markWorktreesQueuedForDeletion: state.markWorktreesQueuedForDeletion, - clearWorktreeDeleteState: state.clearWorktreeDeleteState - })) - ) - const [selectedIds, setSelectedIds] = useState>(() => new Set()) - const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) - const [activeView, setActiveView] = useState('ready') - const [repoSelection, setRepoSelection] = useState>(() => new Set()) - const [filters, setFilters] = useState(DEFAULT_WORKSPACE_CLEANUP_FILTERS) - const [sortKey, setSortKey] = useState('activity') - const [sortDirection, setSortDirection] = useState('asc') - const selectedDefaultsScanAtRef = useRef(null) - const autoScanAttemptedForOpenRef = useRef(false) - const wasOpenRef = useRef(false) - const mountedRef = useMountedRef() - const removal = useWorkspaceCleanupRemovalSession({ - mountedRef, - setSelectedIds, - closeModal, - removeCandidates, - markWorktreesQueuedForDeletion, - clearWorktreeDeleteState - }) - const { clearRowFailures, resetForOpen, ...publicRemoval } = removal - const startScan = useWorkspaceCleanupScanSession({ - open, - mountedRef, - openModal, - scanWorkspaceCleanup, - clearRowFailures - }) - - useEffect(() => { - if (!open) { - wasOpenRef.current = false - autoScanAttemptedForOpenRef.current = false - return - } - if (!wasOpenRef.current) { - wasOpenRef.current = true - autoScanAttemptedForOpenRef.current = false - if (!removal.removalInFlightRef.current) { - setActiveView('ready') - setFilters(DEFAULT_WORKSPACE_CLEANUP_FILTERS) - setSortKey('activity') - setSortDirection('asc') - resetForOpen() - } - } - // Why: reopening mid-batch must preserve progress and avoid a scan the removal would invalidate. - if (!loading && !autoScanAttemptedForOpenRef.current && !removal.removalInFlightRef.current) { - autoScanAttemptedForOpenRef.current = true - startScan({ notifyWhenReady: true }) - } - }, [loading, open, removal.removalInFlightRef, resetForOpen, startScan]) - - const ignoreCandidate = useCallback( - (candidate: WorkspaceCleanupCandidate) => { - void dismissCandidates([candidate]) - .then(() => { - if (!mountedRef.current) { - return - } - setSelectedIds((current) => { - const next = new Set(current) - next.delete(candidate.worktreeId) - return next - }) - }) - .catch((error: unknown) => { - if (!mountedRef.current) { - return - } - toast.error( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7f451a3e2c', - 'Could not ignore cleanup suggestion' - ), - { description: error instanceof Error ? error.message : String(error) } - ) - }) - }, - [dismissCandidates, mountedRef] - ) - - const restoreDismissals = useCallback(() => { - void resetDismissals() - }, [resetDismissals]) - - return { - open, - loading, - selectedIds, - setSelectedIds, - expandedRowIds, - setExpandedRowIds, - activeView, - setActiveView, - repoSelection, - setRepoSelection, - filters, - setFilters, - sortKey, - setSortKey, - sortDirection, - setSortDirection, - selectedDefaultsScanAtRef, - close: closeModal, - markCandidateViewed, - restoreDismissals, - startScan, - ignoreCandidate, - ...publicRemoval - } -} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx new file mode 100644 index 00000000000..10049ceeb4e --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState +} from '../../../../shared/workspace-cleanup-filter-model' +import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets' +import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets' +import type { + WorkspaceCleanupFacetCounts, + WorkspaceCleanupFacetGroupProps, + WorkspaceCleanupFacetOptions +} from './workspace-cleanup-facet-panel-model' + +/** `Mock` erases T's type parameter, so keep the generic call signature and add the spy API. */ +type PatchMock = WorkspaceCleanupFacetGroupProps['onPatch'] & MockInstance + +function createPatchMock(): PatchMock { + return vi.fn() +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +const COUNTS: WorkspaceCleanupFacetCounts = { + activity: 3, + size: 4, + status: 8, + agent: 8, + git: 2, + review: 5, + ticket: 8, + context: 6, + location: 8, + safety: 7 +} + +const OPTIONS: WorkspaceCleanupFacetOptions = { + workspaceStatuses: [{ id: 'in-progress', label: 'In progress' }], + hostIds: ['local', 'ssh:box'], + repos: [ + { id: 'repo-1', label: 'Alpha' }, + { id: 'repo-2', label: 'Beta' } + ], + reviewProviders: ['github', 'gitlab'] +} + +function render(node: ReactNode): void { + act(() => root?.render(node)) +} + +function control(label: string): HTMLElement | null { + return container?.querySelector(`[aria-label="${label}"]`) ?? null +} + +/** React tracks the controlled value, so a bare `input.value =` is swallowed. */ +function typeInto(input: HTMLInputElement | null, value: string): void { + if (!input) { + return + } + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +function renderFacets(filters: WorkspaceCleanupFilterState, onPatch: PatchMock): void { + const props = { filters, counts: COUNTS, totalCount: 8, options: OPTIONS, onPatch } + render( + <> + + + + ) +} + +describe('workspace cleanup facet controls', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('shows a per-facet match count for every group', () => { + renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock()) + + const counts = [...(container?.querySelectorAll('[data-facet-count]') ?? [])].map( + (node) => node.textContent + ) + expect(counts).toHaveLength(10) + expect(counts).toContain('2/8') + expect(counts).toContain('3/8') + }) + + it('takes a user-chosen idle threshold in days rather than a fixed enum', () => { + const onPatch = createPatchMock() + renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch) + + const input = control('Idle for at least') as HTMLInputElement | null + expect(input?.type).toBe('number') + act(() => typeInto(input, '17')) + expect(onPatch).toHaveBeenCalledWith('activity', { idleMinDays: 17 }) + }) + + it('multi-selects git states', () => { + const onPatch = createPatchMock() + const filters = createDefaultWorkspaceCleanupFilterState() + filters.git.states = ['dirty'] + renderFacets(filters, onPatch) + + expect(control('Uncommitted changes')?.getAttribute('aria-checked')).toBe('true') + act(() => control('Unpushed commits')?.click()) + expect(onPatch).toHaveBeenCalledWith('git', { states: ['dirty', 'unpushed'] }) + }) + + it('offers the hosts and providers discovered in the fleet, not a hardcoded list', () => { + renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock()) + + expect(control('GitLab')).not.toBeNull() + expect(container?.textContent).toContain('Alpha') + expect(container?.textContent).toContain('In progress') + }) + + it('cycles a tri-state safety facet', () => { + const onPatch = createPatchMock() + renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch) + + act(() => container?.querySelector('[aria-label="Ignored: Only"]')?.click()) + expect(onPatch).toHaveBeenCalledWith('safety', { dismissed: 'only' }) + }) + + it('labels blocker matching without exposing model tokens', () => { + renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock()) + + expect(control('Blocker match: Has any')).not.toBeNull() + expect(control('Blocker match: Has none')).not.toBeNull() + expect(container?.textContent).not.toContain('any-of') + expect(container?.textContent).not.toContain('none-of') + }) + + it('toggles the unsized escape hatch for the opt-in space scan', () => { + const onPatch = createPatchMock() + renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch) + + const checkbox = control('Include unmeasured workspaces') + expect(checkbox?.getAttribute('data-state')).toBe('checked') + act(() => checkbox?.click()) + expect(onPatch).toHaveBeenCalledWith('size', { includeUnsized: false }) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx new file mode 100644 index 00000000000..7dc1c0d92e7 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx @@ -0,0 +1,225 @@ +import React from 'react' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { cn } from '@/lib/utils' +import { parseWorkspaceCleanupFacetNumber } from './workspace-cleanup-facet-panel-model' + +/** One facet group inside the filter panel, with its own match count. */ +export function FacetSection({ + title, + matchCount, + totalCount, + children +}: { + title: string + matchCount: number + totalCount: number + children: React.ReactNode +}): React.JSX.Element { + return ( +
+
+

+ {title} +

+ + {matchCount}/{totalCount} + +
+
{children}
+
+ ) +} + +/** Multi-select: empty selection leaves the facet unconstrained. */ +export function FacetToggleList({ + label, + values, + selected, + getLabel, + onChange +}: { + label?: string + values: readonly T[] + selected: readonly T[] + getLabel: (value: T) => string + onChange: (next: T[]) => void +}): React.JSX.Element { + const selectedSet = new Set(selected) + return ( + +
+ {values.map((value) => { + const active = selectedSet.has(value) + return ( + + ) + })} +
+
+ ) +} + +/** Single-choice segmented control for tri-state / presence / enum facets. */ +export function FacetChoice({ + label, + value, + options, + getLabel, + onChange +}: { + label: string + value: T + options: readonly T[] + getLabel?: (value: T) => string + onChange: (next: T) => void +}): React.JSX.Element { + return ( + +
+ {options.map((option) => ( + + ))} +
+
+ ) +} + +/** Free-form numeric threshold — deliberately not a fixed 30/90 enum. */ +export function FacetNumberField({ + label, + value, + placeholder, + suffix, + onChange +}: { + label: string + value: number | null + placeholder?: string + suffix?: string + onChange: (next: number | null) => void +}): React.JSX.Element { + return ( + +
+ onChange(parseWorkspaceCleanupFacetNumber(event.target.value))} + className="h-7 w-24 text-xs" + /> + {suffix ? {suffix} : null} +
+
+ ) +} + +export function FacetTextField({ + label, + value, + placeholder, + onChange +}: { + label: string + value: string + placeholder?: string + onChange: (next: string) => void +}): React.JSX.Element { + return ( + + onChange(event.target.value)} + className="h-7 text-xs" + /> + + ) +} + +export function FacetCheckbox({ + id, + label, + checked, + onChange +}: { + id: string + label: string + checked: boolean + onChange: (next: boolean) => void +}): React.JSX.Element { + const checkboxId = `workspace-cleanup-facet-${id}` + return ( +
+ onChange(next === true)} + /> + +
+ ) +} + +function FacetField({ + label, + children +}: { + label?: string + children: React.ReactNode +}): React.JSX.Element { + if (!label) { + return <>{children} + } + return ( +
+
{label}
+ {children} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts new file mode 100644 index 00000000000..8a5dd1a0ed9 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts @@ -0,0 +1,518 @@ +import { describe, expect, it } from 'vitest' +import { + buildWorkspaceCleanupFacetList, + countWorkspaceCleanupMeasuredRows +} from './workspace-cleanup-facets' +import { + buildWorkspaceCleanupSizeIndex, + buildWorkspaceCleanupWorktreeIndex, + getWorkspaceCleanupHostIdentity +} from './workspace-cleanup-host-identity' +import { + DAY, + FACET_NOW, + makeFacetCandidate, + makeFacets, + makeNamedFacets +} from './workspace-cleanup-facet.test.fixture' +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState +} from '../../../../shared/workspace-cleanup-filter-model' +import { + countWorkspaceCleanupFacetMatches, + filterWorkspaceCleanupFacets, + matchesWorkspaceCleanupFilters, + queryWorkspaceCleanupCandidates +} from './workspace-cleanup-query' +import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' + +function filters(patch: (state: WorkspaceCleanupFilterState) => void): WorkspaceCleanupFilterState { + const state = createDefaultWorkspaceCleanupFilterState() + patch(state) + return state +} + +const ANY = filters((state) => { + state.safety.dismissed = 'any' +}) + +describe('facet building', () => { + it('strips the ref prefix and falls back to local host', () => { + const facets = makeFacets({ worktree: { branch: 'refs/heads/feature/x' } }) + expect(facets.branch).toBe('feature/x') + expect(facets.hostId).toBe('local') + }) + + it('preserves a disconnected candidate execution host', () => { + const facets = makeFacets({ + candidate: { connectionId: 'builder', executionHostId: 'ssh:builder' }, + worktree: null + }) + expect(facets.hostId).toBe('ssh:builder') + }) + + it('treats a missing size entry as unsized rather than zero', () => { + expect(makeFacets().sizeBytes).toBeNull() + expect(makeFacets({ sizeBytes: 0 }).sizeBytes).toBe(0) + }) + + it('treats a missing visit stamp as never opened', () => { + expect(makeFacets().lastVisitedAt).toBeNull() + expect(makeFacets({ lastVisitedAt: FACET_NOW }).lastVisitedAt).toBe(FACET_NOW) + }) + + it('resolves the visible workspace status without changing statusless filter semantics', () => { + expect(makeFacets().workspaceStatus).toBeNull() + expect(makeFacets().workspaceStatusLabel).toBe('In progress') + expect(makeFacets({ worktree: { workspaceStatus: 'in-review' } }).workspaceStatusLabel).toBe( + 'In review' + ) + expect(makeFacets({ worktree: null }).workspaceStatusLabel).toBeNull() + }) + + it('only trusts sizes from an ok space scan', () => { + const scanned = [ + { worktreeId: 'a', status: 'ok', sizeBytes: 10 }, + { worktreeId: 'b', status: 'permission-denied', sizeBytes: 99 } + ] as WorkspaceSpaceWorktree[] + const index = buildWorkspaceCleanupSizeIndex(scanned) + expect(index.get('a')).toBe(10) + expect(index.has('b')).toBe(false) + }) + + it('counts measurements only for rows in the current fleet', () => { + const rows = [makeFacets({ sizeBytes: 10 }), makeNamedFacets('unsized')] + expect(countWorkspaceCleanupMeasuredRows(rows)).toBe(1) + }) + + it('indexes worktrees across repos by host and id', () => { + const index = buildWorkspaceCleanupWorktreeIndex({ + 'repo-1': [{ id: 'w1', hostId: 'local' }], + 'repo-2': [{ id: 'w2', hostId: 'ssh:builder' }] + }) + expect([...index.keys()]).toEqual([ + getWorkspaceCleanupHostIdentity('local', 'w1'), + getWorkspaceCleanupHostIdentity('ssh:builder', 'w2') + ]) + }) + + it('keeps same-id worktrees and sizes isolated by host', () => { + const candidates = [ + makeFacetCandidate({ executionHostId: 'local' }), + makeFacetCandidate({ executionHostId: 'ssh:builder', connectionId: 'builder' }) + ] + const sizes = buildWorkspaceCleanupSizeIndex( + [ + { + worktreeId: candidates[0]!.worktreeId, + executionHostId: 'local', + status: 'ok', + sizeBytes: 10 + }, + { + worktreeId: candidates[1]!.worktreeId, + executionHostId: 'ssh:builder', + status: 'ok', + sizeBytes: 20 + } + ], + candidates + ) + + expect(sizes.get(getWorkspaceCleanupHostIdentity('local', candidates[0]!.worktreeId))).toBe(10) + expect( + sizes.get(getWorkspaceCleanupHostIdentity('ssh:builder', candidates[1]!.worktreeId)) + ).toBe(20) + expect(sizes.has(candidates[0]!.worktreeId)).toBe(false) + }) + + it('does not apply a legacy size to duplicate candidate ids', () => { + const candidates = [ + makeFacetCandidate({ executionHostId: 'local' }), + makeFacetCandidate({ executionHostId: 'ssh:builder', connectionId: 'builder' }) + ] + const sizes = buildWorkspaceCleanupSizeIndex( + [{ worktreeId: candidates[0]!.worktreeId, status: 'ok', sizeBytes: 10 }], + candidates + ) + + expect(sizes.size).toBe(0) + }) + + it('does not apply duplicate legacy size rows to one current candidate', () => { + const candidate = makeFacetCandidate({ executionHostId: 'local' }) + const sizes = buildWorkspaceCleanupSizeIndex( + [ + { worktreeId: candidate.worktreeId, status: 'ok', sizeBytes: 10 }, + { worktreeId: candidate.worktreeId, status: 'ok', sizeBytes: 20 } + ], + [candidate] + ) + + expect(sizes.size).toBe(0) + }) + + it('does not assign legacy worktree metadata with multi-host repo ownership', () => { + const index = buildWorkspaceCleanupWorktreeIndex( + { 'repo-1': [{ id: 'w1', repoId: 'repo-1' }] }, + [ + { id: 'repo-1', executionHostId: 'local' }, + { id: 'repo-1', executionHostId: 'ssh:builder' } + ] + ) + + expect(index.size).toBe(0) + }) + + it('counts a workspace with nothing attached as completely empty', () => { + expect(makeFacets().isCompletelyEmpty).toBe(true) + expect(makeFacets({ worktree: { comment: 'keep' } }).isCompletelyEmpty).toBe(false) + expect(makeFacets({ review: {} }).isCompletelyEmpty).toBe(false) + expect(makeFacets({ worktree: { linkedIssue: 12 } }).isCompletelyEmpty).toBe(false) + }) +}) + +describe('query search', () => { + it('matches branch, host, status, provider, and blockers', () => { + const facets = makeFacets({ + candidate: { blockers: ['live-agent'] }, + worktree: { + branch: 'refs/heads/fix/login', + hostId: 'ssh:builder', + workspaceStatus: 'in-review' + }, + review: { provider: 'gitlab', label: 'MR #7' } + }) + for (const term of ['fix/login', 'ssh:builder', 'in-review', 'gitlab', 'live-agent']) { + expect(matchesWorkspaceCleanupFilters(facets, { ...ANY, query: term }, FACET_NOW)).toBe(true) + } + expect(matchesWorkspaceCleanupFilters(facets, { ...ANY, query: 'nope' }, FACET_NOW)).toBe(false) + }) +}) + +describe('activity filter', () => { + it('uses the user-chosen day threshold against the chosen signal', () => { + const recentlyVisited = makeFacets({ lastVisitedAt: FACET_NOW - 5 * DAY }) + const staleVisit = makeFacets({ lastVisitedAt: FACET_NOW - 45 * DAY }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.activity.idleSignal = 'last-visited' + s.activity.idleMinDays = 30 + }) + expect(matchesWorkspaceCleanupFilters(recentlyVisited, state, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(staleVisit, state, FACET_NOW)).toBe(true) + }) + + it('reads a missing signal as maximally idle', () => { + const state = filters((s) => { + s.safety.dismissed = 'any' + s.activity.idleMinDays = 365 + }) + expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true) + }) + + it('separates background activity from a real visit', () => { + const busyButUnopened = makeFacets({ candidate: { lastActivityAt: FACET_NOW } }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.activity.neverVisited = true + }) + expect(matchesWorkspaceCleanupFilters(busyButUnopened, state, FACET_NOW)).toBe(true) + expect( + matchesWorkspaceCleanupFilters(makeFacets({ lastVisitedAt: FACET_NOW }), state, FACET_NOW) + ).toBe(false) + }) +}) + +describe('size filter', () => { + it('keeps unsized rows unless the user opts out', () => { + const unsized = makeFacets() + expect(matchesWorkspaceCleanupFilters(unsized, ANY, FACET_NOW)).toBe(true) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.size.includeUnsized = false + }) + expect(matchesWorkspaceCleanupFilters(unsized, state, FACET_NOW)).toBe(false) + }) + + it('applies min and max byte bounds', () => { + const state = filters((s) => { + s.safety.dismissed = 'any' + s.size.minBytes = 100 + s.size.maxBytes = 200 + }) + expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 150 }), state, FACET_NOW)).toBe( + true + ) + expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 99 }), state, FACET_NOW)).toBe( + false + ) + expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 201 }), state, FACET_NOW)).toBe( + false + ) + }) +}) + +describe('status filter', () => { + it('matches open workspace-status strings and statusless rows separately', () => { + const inReview = makeFacets({ worktree: { workspaceStatus: 'in-review' } }) + const statusless = makeFacets() + const state = filters((s) => { + s.safety.dismissed = 'any' + s.status.workspaceStatuses = ['in-review'] + s.status.matchStatusless = false + }) + expect(matchesWorkspaceCleanupFilters(inReview, state, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(statusless, state, FACET_NOW)).toBe(false) + state.status.matchStatusless = true + expect(matchesWorkspaceCleanupFilters(statusless, state, FACET_NOW)).toBe(true) + }) + + it('excludes statusless rows even when no named status is selected', () => { + const state = filters((s) => { + s.safety.dismissed = 'any' + s.status.workspaceStatuses = [] + s.status.matchStatusless = false + }) + expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(false) + expect( + matchesWorkspaceCleanupFilters( + makeFacets({ worktree: { workspaceStatus: 'in-review' } }), + state, + FACET_NOW + ) + ).toBe(true) + }) + + it('applies tri-state flags', () => { + const archived = makeFacets({ worktree: { isArchived: true } }) + const only = filters((s) => { + s.safety.dismissed = 'any' + s.status.archived = 'only' + }) + const exclude = filters((s) => { + s.safety.dismissed = 'any' + s.status.archived = 'exclude' + }) + expect(matchesWorkspaceCleanupFilters(archived, only, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(archived, exclude, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), exclude, FACET_NOW)).toBe(true) + }) +}) + +describe('agent, git, review, ticket, context, and location filters', () => { + it('filters by live agent rollup and retained done agents', () => { + const state = filters((s) => { + s.safety.dismissed = 'any' + s.agent.states = ['permission'] + }) + expect( + matchesWorkspaceCleanupFilters(makeFacets({ agentStatus: 'permission' }), state, FACET_NOW) + ).toBe(true) + expect( + matchesWorkspaceCleanupFilters(makeFacets({ agentStatus: 'working' }), state, FACET_NOW) + ).toBe(false) + expect(makeFacets().agentState).toBe('idle') + + const retained = filters((s) => { + s.safety.dismissed = 'any' + s.agent.retainedDoneAgents = 'only' + }) + const withRetained = makeFacets({ + candidate: { + localContext: { + ...makeFacetCandidate().localContext, + retainedDoneAgentCount: 2 + } + } + }) + expect(matchesWorkspaceCleanupFilters(withRetained, retained, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(makeFacets(), retained, FACET_NOW)).toBe(false) + }) + + it('filters by git state, ahead counts, branch text, prunable and locked', () => { + const unpushed = makeFacets({ + candidate: { git: { clean: true, upstreamAhead: 3, upstreamBehind: 1, checkedAt: 1 } } + }) + expect(unpushed.gitState).toBe('unpushed') + const state = filters((s) => { + s.safety.dismissed = 'any' + s.git.states = ['unpushed'] + s.git.minAhead = 2 + }) + expect(matchesWorkspaceCleanupFilters(unpushed, state, FACET_NOW)).toBe(true) + state.git.minAhead = 4 + expect(matchesWorkspaceCleanupFilters(unpushed, state, FACET_NOW)).toBe(false) + + const branchState = filters((s) => { + s.safety.dismissed = 'any' + s.git.branchQuery = 'ALPH' + }) + expect(matchesWorkspaceCleanupFilters(makeFacets(), branchState, FACET_NOW)).toBe(true) + + const prunable = filters((s) => { + s.safety.dismissed = 'any' + s.git.prunable = 'only' + }) + expect( + matchesWorkspaceCleanupFilters( + makeFacets({ worktree: { prunable: true } }), + prunable, + FACET_NOW + ) + ).toBe(true) + expect(matchesWorkspaceCleanupFilters(makeFacets(), prunable, FACET_NOW)).toBe(false) + }) + + it('keeps review filtering provider-general and treats draft as a state', () => { + const draftMr = makeFacets({ review: { provider: 'gitlab', state: 'draft', label: 'MR #7' } }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.review.presence = 'some' + s.review.states = ['draft'] + s.review.providers = ['gitlab', 'bitbucket'] + }) + expect(matchesWorkspaceCleanupFilters(draftMr, state, FACET_NOW)).toBe(true) + state.review.providers = ['github'] + expect(matchesWorkspaceCleanupFilters(draftMr, state, FACET_NOW)).toBe(false) + + const none = filters((s) => { + s.safety.dismissed = 'any' + s.review.presence = 'none' + }) + expect(matchesWorkspaceCleanupFilters(makeFacets(), none, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(draftMr, none, FACET_NOW)).toBe(false) + }) + + it('filters by ticket source', () => { + const linear = makeFacets({ worktree: { linkedLinearIssue: 'STA-1' } }) + expect(linear.ticketSources).toEqual(['linear']) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.ticket.presence = 'some' + s.ticket.sources = ['linear'] + }) + expect(matchesWorkspaceCleanupFilters(linear, state, FACET_NOW)).toBe(true) + expect( + matchesWorkspaceCleanupFilters(makeFacets({ worktree: { linkedIssue: 9 } }), state, FACET_NOW) + ).toBe(false) + }) + + it('filters by local context and completely-empty', () => { + const busy = makeFacets({ + candidate: { + localContext: { ...makeFacetCandidate().localContext, terminalTabCount: 2 } + } + }) + const hasContext = filters((s) => { + s.safety.dismissed = 'any' + s.context.presence = 'some' + }) + expect(matchesWorkspaceCleanupFilters(busy, hasContext, FACET_NOW)).toBe(true) + const empty = filters((s) => { + s.safety.dismissed = 'any' + s.context.completelyEmpty = true + }) + expect(matchesWorkspaceCleanupFilters(busy, empty, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), empty, FACET_NOW)).toBe(true) + }) + + it('filters by execution host, repo, and path prefix', () => { + const remote = makeFacets({ worktree: { hostId: 'ssh:builder' } }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.location.hostIds = ['ssh:builder'] + s.location.repoIds = ['repo-1'] + s.location.pathPrefix = '/repo/' + }) + expect(matchesWorkspaceCleanupFilters(remote, state, FACET_NOW)).toBe(true) + state.location.pathPrefix = '/other/' + expect(matchesWorkspaceCleanupFilters(remote, state, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(false) + }) +}) + +describe('safety filter', () => { + it('supports any-of and none-of blocker selection', () => { + const live = makeFacets({ + candidate: { blockers: ['live-agent', 'pinned'], tier: 'protected' } + }) + const anyOf = filters((s) => { + s.safety.dismissed = 'any' + s.safety.blockers = ['live-agent'] + s.safety.blockerMode = 'any-of' + }) + const noneOf = filters((s) => { + s.safety.dismissed = 'any' + s.safety.blockers = ['live-agent'] + s.safety.blockerMode = 'none-of' + }) + expect(matchesWorkspaceCleanupFilters(live, anyOf, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(live, noneOf, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), noneOf, FACET_NOW)).toBe(true) + }) + + it('includes dismissed rows by default and narrows them on demand', () => { + const dismissed = makeFacets({ dismissed: true }) + expect( + matchesWorkspaceCleanupFilters( + dismissed, + createDefaultWorkspaceCleanupFilterState(), + FACET_NOW + ) + ).toBe(true) + const exclude = filters((s) => { + s.safety.dismissed = 'exclude' + }) + expect(matchesWorkspaceCleanupFilters(dismissed, exclude, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), exclude, FACET_NOW)).toBe(true) + const only = filters((s) => { + s.safety.dismissed = 'only' + }) + expect(matchesWorkspaceCleanupFilters(dismissed, only, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(makeFacets(), only, FACET_NOW)).toBe(false) + }) + + it('restricts to rows the policy would actually let the user delete', () => { + const blocked = makeFacets({ candidate: { blockers: ['dirty-files'], tier: 'review' } }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.safety.selectableOnly = true + }) + expect(matchesWorkspaceCleanupFilters(blocked, state, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true) + }) +}) + +describe('query pipeline', () => { + it('reports counts and selectable ids', () => { + const rows = [ + makeNamedFacets('alpha'), + makeNamedFacets('beta', { candidate: { blockers: ['dirty-files'], tier: 'review' } }) + ] + expect(filterWorkspaceCleanupFacets(rows, ANY, FACET_NOW)).toHaveLength(2) + const result = queryWorkspaceCleanupCandidates( + buildWorkspaceCleanupFacetList([makeFacetCandidate()]).map((f) => f.candidate), + { filters: ANY, sort: { field: 'name', direction: 'asc' } }, + {}, + FACET_NOW + ) + expect(result.totalCount).toBe(1) + expect(result.matchedCount).toBe(1) + expect(result.selectableWorktreeIds).toEqual(['repo-1::/repo/alpha']) + }) + + it('counts per-facet matches independently of the other groups', () => { + const rows = [makeNamedFacets('alpha', { sizeBytes: 10 }), makeNamedFacets('beta')] + const state = filters((s) => { + s.safety.dismissed = 'any' + s.size.includeUnsized = false + s.git.states = ['dirty'] + }) + const counts = countWorkspaceCleanupFacetMatches(rows, state, FACET_NOW) + expect(counts.size).toBe(1) + expect(counts.git).toBe(0) + expect(counts.safety).toBe(2) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts new file mode 100644 index 00000000000..9ea2cd09adf --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts @@ -0,0 +1,175 @@ +import { translate } from '@/i18n/i18n' +import type { HostedReviewProvider } from '../../../../shared/hosted-review' +import type { WorkspaceCleanupTier } from '../../../../shared/workspace-cleanup' +import type { + WorkspaceCleanupAgentState, + WorkspaceCleanupBlockerMode, + WorkspaceCleanupGitState, + WorkspaceCleanupIdleSignal, + WorkspaceCleanupPresence, + WorkspaceCleanupReviewState, + WorkspaceCleanupSortField, + WorkspaceCleanupTicketSource, + WorkspaceCleanupTriState +} from '../../../../shared/workspace-cleanup-filter-model' + +export function getWorkspaceCleanupSortFieldLabel(field: WorkspaceCleanupSortField): string { + switch (field) { + case 'last-activity': + return translate('components.workspace.cleanup.browse.sort.lastActivity', 'Last activity') + case 'last-visited': + return translate('components.workspace.cleanup.browse.sort.lastVisited', 'Last opened') + case 'created': + return translate('components.workspace.cleanup.browse.sort.created', 'Created') + case 'size': + return translate('components.workspace.cleanup.browse.sort.size', 'Size') + case 'name': + return translate('components.workspace.cleanup.browse.sort.name', 'Workspace') + case 'repo': + return translate('components.workspace.cleanup.browse.sort.repo', 'Repository') + case 'path': + return translate('components.workspace.cleanup.browse.sort.path', 'Path') + case 'host': + return translate('components.workspace.cleanup.browse.sort.host', 'Host') + case 'workspace-status': + return translate('components.workspace.cleanup.browse.sort.workspaceStatus', 'Status') + case 'agent': + return translate('components.workspace.cleanup.browse.sort.agent', 'Agent') + case 'git': + return translate('components.workspace.cleanup.browse.sort.git', 'Git') + case 'ahead': + return translate('components.workspace.cleanup.browse.sort.ahead', 'Ahead') + case 'behind': + return translate('components.workspace.cleanup.browse.sort.behind', 'Behind') + case 'branch': + return translate('components.workspace.cleanup.browse.sort.branch', 'Branch') + case 'review': + return translate('components.workspace.cleanup.browse.sort.review', 'Review') + case 'ticket': + return translate('components.workspace.cleanup.browse.sort.ticket', 'Ticket') + case 'local-context': + return translate('components.workspace.cleanup.browse.sort.localContext', 'Open context') + case 'tier': + return translate('components.workspace.cleanup.browse.sort.tier', 'Safety') + case 'blocker-count': + return translate('components.workspace.cleanup.browse.sort.blockerCount', 'Blockers') + } +} + +export function getWorkspaceCleanupGitStateLabel(state: WorkspaceCleanupGitState): string { + switch (state) { + case 'clean': + return translate('components.workspace.cleanup.browse.git.clean', 'Clean') + case 'dirty': + return translate('components.workspace.cleanup.browse.git.dirty', 'Uncommitted changes') + case 'unpushed': + return translate('components.workspace.cleanup.browse.git.unpushed', 'Unpushed commits') + case 'unknown': + return translate('components.workspace.cleanup.browse.git.unknown', 'Not checked') + } +} + +export function getWorkspaceCleanupAgentStateLabel(state: WorkspaceCleanupAgentState): string { + switch (state) { + case 'working': + return translate('components.workspace.cleanup.browse.agent.working', 'Working') + case 'permission': + return translate('components.workspace.cleanup.browse.agent.permission', 'Waiting on you') + case 'idle': + return translate('components.workspace.cleanup.browse.agent.idle', 'Idle') + } +} + +export function getWorkspaceCleanupReviewStateLabel(state: WorkspaceCleanupReviewState): string { + switch (state) { + case 'open': + return translate('components.workspace.cleanup.browse.review.open', 'Open') + case 'draft': + return translate('components.workspace.cleanup.browse.review.draft', 'Draft') + case 'merged': + return translate('components.workspace.cleanup.browse.review.merged', 'Merged') + case 'closed': + return translate('components.workspace.cleanup.browse.review.closed', 'Closed') + case 'unknown': + return translate('components.workspace.cleanup.browse.review.unknown', 'Unknown') + } +} + +/** Provider-general: GitLab MRs and Azure DevOps PRs are first-class here. */ +export function getWorkspaceCleanupReviewProviderLabel(provider: HostedReviewProvider): string { + switch (provider) { + case 'github': + return 'GitHub' + case 'gitlab': + return 'GitLab' + case 'bitbucket': + return 'Bitbucket' + case 'azure-devops': + return 'Azure DevOps' + case 'gitea': + return 'Gitea' + case 'unsupported': + return translate('components.workspace.cleanup.browse.review.otherProvider', 'Other') + } +} + +export function getWorkspaceCleanupTicketSourceLabel(source: WorkspaceCleanupTicketSource): string { + switch (source) { + case 'work-item': + return translate('components.workspace.cleanup.browse.ticket.workItem', 'Work item') + case 'linear': + return translate('components.workspace.cleanup.browse.ticket.linear', 'Linear') + case 'issue': + return translate('components.workspace.cleanup.browse.ticket.issue', 'Issue') + } +} + +export function getWorkspaceCleanupTierLabel(tier: WorkspaceCleanupTier): string { + switch (tier) { + case 'ready': + return translate('components.workspace.cleanup.browse.tier.ready', 'Ready') + case 'review': + return translate('components.workspace.cleanup.browse.tier.review', 'Needs review') + case 'protected': + return translate('components.workspace.cleanup.browse.tier.protected', 'Protected') + } +} + +export function getWorkspaceCleanupIdleSignalLabel(signal: WorkspaceCleanupIdleSignal): string { + switch (signal) { + case 'last-visited': + return translate('components.workspace.cleanup.browse.idleSignal.lastVisited', 'Not opened') + case 'last-activity': + return translate('components.workspace.cleanup.browse.idleSignal.lastActivity', 'No activity') + case 'created': + return translate('components.workspace.cleanup.browse.idleSignal.created', 'Created before') + } +} + +export function getWorkspaceCleanupTriStateLabel(value: WorkspaceCleanupTriState): string { + switch (value) { + case 'any': + return translate('components.workspace.cleanup.browse.triState.any', 'Any') + case 'only': + return translate('components.workspace.cleanup.browse.triState.only', 'Only') + case 'exclude': + return translate('components.workspace.cleanup.browse.triState.exclude', 'Exclude') + } +} + +export function getWorkspaceCleanupPresenceLabel(value: WorkspaceCleanupPresence): string { + switch (value) { + case 'any': + return translate('components.workspace.cleanup.browse.presence.any', 'Any') + case 'some': + return translate('components.workspace.cleanup.browse.presence.some', 'Has one') + case 'none': + return translate('components.workspace.cleanup.browse.presence.none', 'Has none') + } +} + +export function getWorkspaceCleanupBlockerModeLabel(value: WorkspaceCleanupBlockerMode): string { + return value === 'any-of' + ? translate('components.workspace.cleanup.browse.blockerModeAny', 'Has any') + : translate('components.workspace.cleanup.browse.blockerModeNone', 'Has none') +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts new file mode 100644 index 00000000000..988a31c1489 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts @@ -0,0 +1,214 @@ +import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' +import type { + WorkspaceCleanupActivityFilter, + WorkspaceCleanupAgentFilter, + WorkspaceCleanupContextFilterState, + WorkspaceCleanupGitFilterState, + WorkspaceCleanupLocationFilter, + WorkspaceCleanupPresence, + WorkspaceCleanupReviewFilterState, + WorkspaceCleanupSafetyFilter, + WorkspaceCleanupSizeFilter, + WorkspaceCleanupStatusFilter, + WorkspaceCleanupTicketFilter, + WorkspaceCleanupTriState +} from '../../../../shared/workspace-cleanup-filter-model' + +export const WORKSPACE_CLEANUP_DAY_MS = 24 * 60 * 60 * 1000 + +export function matchesWorkspaceCleanupTriState( + filter: WorkspaceCleanupTriState, + value: boolean +): boolean { + return filter === 'any' || (filter === 'only' ? value : !value) +} + +export function matchesWorkspaceCleanupPresence( + filter: WorkspaceCleanupPresence, + value: boolean +): boolean { + return filter === 'any' || (filter === 'some' ? value : !value) +} + +export function matchesWorkspaceCleanupActivity( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupActivityFilter, + now: number +): boolean { + if (filter.neverVisited && facets.lastVisitedAt !== null) { + return false + } + if (filter.idleMinDays === null) { + return true + } + const signalAt = getIdleSignalAt(facets, filter) + // Why: a missing signal means Orca has no evidence of recent use, so it + // reads as maximally idle rather than being filtered out. + return signalAt === null || now - signalAt >= filter.idleMinDays * WORKSPACE_CLEANUP_DAY_MS +} + +export function matchesWorkspaceCleanupSize( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupSizeFilter +): boolean { + if (facets.sizeBytes === null) { + return filter.includeUnsized + } + if (filter.minBytes !== null && facets.sizeBytes < filter.minBytes) { + return false + } + return filter.maxBytes === null || facets.sizeBytes <= filter.maxBytes +} + +export function matchesWorkspaceCleanupStatus( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupStatusFilter +): boolean { + if (!matchesWorkspaceStatusList(facets, filter)) { + return false + } + return ( + matchesWorkspaceCleanupTriState(filter.archived, facets.isArchived) && + matchesWorkspaceCleanupTriState(filter.pinned, facets.isPinned) && + matchesWorkspaceCleanupTriState(filter.unread, facets.isUnread) && + matchesWorkspaceCleanupTriState(filter.comment, facets.hasComment) + ) +} + +export function matchesWorkspaceCleanupAgent( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupAgentFilter +): boolean { + if (filter.states.length > 0 && !filter.states.includes(facets.agentState)) { + return false + } + return matchesWorkspaceCleanupTriState( + filter.retainedDoneAgents, + facets.retainedDoneAgentCount > 0 + ) +} + +export function matchesWorkspaceCleanupGit( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupGitFilterState +): boolean { + if (filter.states.length > 0 && !filter.states.includes(facets.gitState)) { + return false + } + if (filter.minAhead !== null && (facets.upstreamAhead ?? 0) < filter.minAhead) { + return false + } + if (filter.minBehind !== null && (facets.upstreamBehind ?? 0) < filter.minBehind) { + return false + } + const branchQuery = filter.branchQuery.trim().toLowerCase() + if (branchQuery && !facets.branch.toLowerCase().includes(branchQuery)) { + return false + } + return ( + matchesWorkspaceCleanupTriState(filter.prunable, facets.isPrunable) && + matchesWorkspaceCleanupTriState(filter.locked, facets.isLocked) + ) +} + +export function matchesWorkspaceCleanupReview( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupReviewFilterState +): boolean { + if (!matchesWorkspaceCleanupPresence(filter.presence, facets.review.hasReview)) { + return false + } + if (filter.states.length > 0) { + if (facets.reviewState === null || !filter.states.includes(facets.reviewState)) { + return false + } + } + if (filter.providers.length === 0) { + return true + } + return facets.review.provider !== null && filter.providers.includes(facets.review.provider) +} + +export function matchesWorkspaceCleanupTicket( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupTicketFilter +): boolean { + if (!matchesWorkspaceCleanupPresence(filter.presence, facets.ticketSources.length > 0)) { + return false + } + return ( + filter.sources.length === 0 || + filter.sources.some((source) => facets.ticketSources.includes(source)) + ) +} + +export function matchesWorkspaceCleanupContext( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupContextFilterState +): boolean { + if (!matchesWorkspaceCleanupPresence(filter.presence, facets.hasLocalContext)) { + return false + } + return !filter.completelyEmpty || facets.isCompletelyEmpty +} + +export function matchesWorkspaceCleanupLocation( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupLocationFilter +): boolean { + if (filter.hostIds.length > 0 && !filter.hostIds.includes(facets.hostId)) { + return false + } + if (filter.repoIds.length > 0 && !filter.repoIds.includes(facets.repoId)) { + return false + } + const prefix = filter.pathPrefix.trim() + return prefix.length === 0 || facets.path.startsWith(prefix) +} + +export function matchesWorkspaceCleanupSafety( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupSafetyFilter +): boolean { + if (filter.tiers.length > 0 && !filter.tiers.includes(facets.tier)) { + return false + } + if (!matchesWorkspaceCleanupTriState(filter.dismissed, facets.isDismissed)) { + return false + } + if (filter.selectableOnly && !facets.isSelectable) { + return false + } + if (filter.blockers.length === 0) { + return true + } + const hit = filter.blockers.some((blocker) => facets.blockers.includes(blocker)) + return filter.blockerMode === 'any-of' ? hit : !hit +} + +function matchesWorkspaceStatusList( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupStatusFilter +): boolean { + if (facets.workspaceStatus === null) { + return filter.matchStatusless + } + return ( + filter.workspaceStatuses.length === 0 || + filter.workspaceStatuses.includes(facets.workspaceStatus) + ) +} + +function getIdleSignalAt( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupActivityFilter +): number | null { + switch (filter.idleSignal) { + case 'last-visited': + return facets.lastVisitedAt + case 'last-activity': + return facets.lastActivityAt + case 'created': + return facets.createdAt + } +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-panel-model.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-panel-model.ts new file mode 100644 index 00000000000..60a097a00e1 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-panel-model.ts @@ -0,0 +1,46 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { HostedReviewProvider } from '../../../../shared/hosted-review' +import type { WorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' + +export type WorkspaceCleanupFacetCounts = Record< + keyof Omit, + number +> + +/** Facet values discovered from the loaded fleet, not a hardcoded enum. */ +export type WorkspaceCleanupFacetOptions = { + workspaceStatuses: readonly { id: string; label: string }[] + hostIds: readonly ExecutionHostId[] + repos: readonly { id: string; label: string }[] + reviewProviders: readonly HostedReviewProvider[] +} + +export type WorkspaceCleanupFacetGroupProps = { + filters: WorkspaceCleanupFilterState + counts: WorkspaceCleanupFacetCounts + totalCount: number + options: WorkspaceCleanupFacetOptions + onPatch: ( + key: K, + value: Partial | WorkspaceCleanupFilterState[K] + ) => void +} + +export const WORKSPACE_CLEANUP_MEGABYTE = 1024 * 1024 + +export function toMegabytes(bytes: number | null): number | null { + return bytes === null ? null : Math.round(bytes / WORKSPACE_CLEANUP_MEGABYTE) +} + +export function fromMegabytes(megabytes: number | null): number | null { + return megabytes === null ? null : Math.round(megabytes * WORKSPACE_CLEANUP_MEGABYTE) +} + +export function parseWorkspaceCleanupFacetNumber(raw: string): number | null { + const trimmed = raw.trim() + if (trimmed === '') { + return null + } + const parsed = Number(trimmed) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts new file mode 100644 index 00000000000..e6949e35e57 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts @@ -0,0 +1,143 @@ +import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' +import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/types' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + buildWorkspaceCleanupFacets, + type WorkspaceCleanupFacetSources, + type WorkspaceCleanupFacets +} from './workspace-cleanup-facets' +import { + getWorkspaceCleanupReviewInfo, + type WorkspaceCleanupRendererStateInputs, + type WorkspaceCleanupReviewInfo, + type WorkspaceCleanupReviewLookup +} from './workspace-cleanup-presentation' +import { + getWorkspaceCleanupCandidateHostId, + getWorkspaceCleanupHostIdentity +} from './workspace-cleanup-host-identity' + +/** + * Streaming ticks replace only the candidate objects they touched, so all + * per-candidate work here is cached on candidate object identity; unchanged + * rows reuse their previous facet/review objects and the row array itself + * keeps identity when nothing changed, which is what lets React.memo and + * downstream memos hold. + * + * The caches are owned by useMemo keyed on their invalidation inputs (never + * refs — ref writes during render are unsafe under concurrent rendering). + * Filling them during render is safe because they are content-addressed and + * idempotent: a replayed or discarded render stores the same value for the + * same key. + */ +export type WorkspaceCleanupReviewInfoCache = WeakMap< + WorkspaceCleanupCandidate, + WorkspaceCleanupReviewInfo +> + +export function createWorkspaceCleanupReviewInfoCache(): WorkspaceCleanupReviewInfoCache { + return new WeakMap() +} + +export function computeWorkspaceCleanupReviewInfoIndex(args: { + candidates: readonly WorkspaceCleanupCandidate[] + candidateIdCounts: ReadonlyMap + reviewSources: WorkspaceCleanupRendererStateInputs + reviewLookup: WorkspaceCleanupReviewLookup + cache: WorkspaceCleanupReviewInfoCache +}): Map { + const { candidates, candidateIdCounts, reviewSources, reviewLookup, cache } = args + const infos = new Map() + for (const candidate of candidates) { + let info = cache.get(candidate) + if (info === undefined) { + info = getWorkspaceCleanupReviewInfo(candidate, reviewSources, reviewLookup) + cache.set(candidate, info) + } + infos.set( + getWorkspaceCleanupHostIdentity( + getWorkspaceCleanupCandidateHostId(candidate), + candidate.worktreeId + ), + info + ) + if (candidateIdCounts.get(candidate.worktreeId) === 1) { + infos.set(candidate.worktreeId, info) + } + } + return infos +} + +type FacetCacheEntry = { + facet: WorkspaceCleanupFacets + sizeBytes: number | null | undefined + lastVisitedAt: number | undefined + agentState: LiveAgentWorktreeStatus | undefined + review: WorkspaceCleanupReviewInfo | undefined + isDismissed: boolean +} + +export type WorkspaceCleanupFacetListCache = { + byCandidate: WeakMap + lastList: WorkspaceCleanupFacets[] +} + +export function createWorkspaceCleanupFacetListCache(): WorkspaceCleanupFacetListCache { + return { byCandidate: new WeakMap(), lastList: [] } +} + +export function computeWorkspaceCleanupFacetList(args: { + candidates: readonly WorkspaceCleanupCandidate[] + sources: Required> & { + workspaceStatuses: readonly WorkspaceStatusDefinition[] + } + cache: WorkspaceCleanupFacetListCache +}): WorkspaceCleanupFacets[] { + const { candidates, sources, cache } = args + const list = candidates.map((candidate) => { + const hostIdentity = getWorkspaceCleanupHostIdentity( + getWorkspaceCleanupCandidateHostId(candidate), + candidate.worktreeId + ) + // Per-id projections mirror buildWorkspaceCleanupFacets' lookups; the + // cached facet is valid only while every projected input is unchanged. + const sizeBytes = + sources.sizeBytesByWorktreeId.get(hostIdentity) ?? + sources.sizeBytesByWorktreeId.get(candidate.worktreeId) + const lastVisitedAt = sources.lastVisitedAtByWorktreeId[candidate.worktreeId] + const agentState = sources.liveAgentStatusByWorktreeId.get(candidate.worktreeId) + const review = + sources.reviewInfoByWorktreeId.get(hostIdentity) ?? + sources.reviewInfoByWorktreeId.get(candidate.worktreeId) + const isDismissed = sources.dismissedWorktreeIds.has(candidate.worktreeId) + const cached = cache.byCandidate.get(candidate) + if ( + cached && + cached.sizeBytes === sizeBytes && + cached.lastVisitedAt === lastVisitedAt && + cached.agentState === agentState && + cached.review === review && + cached.isDismissed === isDismissed + ) { + return cached.facet + } + const facet = buildWorkspaceCleanupFacets(candidate, sources) + cache.byCandidate.set(candidate, { + facet, + sizeBytes, + lastVisitedAt, + agentState, + review, + isDismissed + }) + return facet + }) + // Why: reusing the previous array identity when no row changed lets every + // downstream memo skip its O(N) pass on no-op streaming ticks. + const previous = cache.lastList + if (previous.length === list.length && list.every((facet, index) => facet === previous[index])) { + return previous + } + cache.lastList = list + return list +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts new file mode 100644 index 00000000000..4da525a1352 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { DAY, FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture' +import { + isWorkspaceCleanupAbsentLastSortField, + sortWorkspaceCleanupFacets +} from './workspace-cleanup-facet-sort' +import type { WorkspaceCleanupSortField } from '../../../../shared/workspace-cleanup-filter-model' + +function names( + rows: ReturnType[], + field: WorkspaceCleanupSortField, + direction: 'asc' | 'desc' +): string[] { + return sortWorkspaceCleanupFacets(rows, { field, direction }).map((row) => row.displayName) +} + +describe('absent values', () => { + it('sinks unsized workspaces to the bottom in both directions', () => { + const rows = [ + makeNamedFacets('unsized'), + makeNamedFacets('small', { sizeBytes: 10 }), + makeNamedFacets('big', { sizeBytes: 900 }) + ] + expect(names(rows, 'size', 'desc')).toEqual(['big', 'small', 'unsized']) + expect(names(rows, 'size', 'asc')).toEqual(['small', 'big', 'unsized']) + }) + + it('sinks never-visited workspaces to the bottom in both directions', () => { + const rows = [ + makeNamedFacets('never'), + makeNamedFacets('old', { lastVisitedAt: FACET_NOW - 50 * DAY }), + makeNamedFacets('recent', { lastVisitedAt: FACET_NOW }) + ] + expect(names(rows, 'last-visited', 'asc')).toEqual(['old', 'recent', 'never']) + expect(names(rows, 'last-visited', 'desc')).toEqual(['recent', 'old', 'never']) + }) + + it('sinks review-less and ticket-less workspaces to the bottom', () => { + const rows = [ + makeNamedFacets('none'), + makeNamedFacets('merged', { review: { state: 'merged' } }), + makeNamedFacets('open', { review: { state: 'open' } }) + ] + expect(names(rows, 'review', 'desc')).toEqual(['open', 'merged', 'none']) + + const ticketRows = [ + makeNamedFacets('untracked'), + makeNamedFacets('linear', { worktree: { linkedLinearIssue: 'STA-1' } }) + ] + expect(names(ticketRows, 'ticket', 'asc')).toEqual(['linear', 'untracked']) + }) + + it('marks exactly the optional-value fields as absent-last', () => { + expect(isWorkspaceCleanupAbsentLastSortField('size')).toBe(true) + expect(isWorkspaceCleanupAbsentLastSortField('name')).toBe(false) + }) +}) + +describe('ranked fields', () => { + it('orders git states from safest to riskiest', () => { + const rows = [ + makeNamedFacets('unpushed', { + candidate: { git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } } + }), + makeNamedFacets('clean'), + makeNamedFacets('dirty', { + candidate: { git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } } + }), + makeNamedFacets('unknown', { + candidate: { git: { clean: null, upstreamAhead: 0, upstreamBehind: 0, checkedAt: null } } + }) + ] + expect(names(rows, 'git', 'asc')).toEqual(['clean', 'unknown', 'dirty', 'unpushed']) + }) + + it('orders tiers ready to protected and agents idle to permission', () => { + const tierRows = [ + makeNamedFacets('protectedRow', { candidate: { tier: 'protected' } }), + makeNamedFacets('readyRow', { candidate: { tier: 'ready' } }), + makeNamedFacets('reviewRow', { candidate: { tier: 'review' } }) + ] + expect(names(tierRows, 'tier', 'asc')).toEqual(['readyRow', 'reviewRow', 'protectedRow']) + + const agentRows = [ + makeNamedFacets('permission', { agentStatus: 'permission' }), + makeNamedFacets('idle'), + makeNamedFacets('working', { agentStatus: 'working' }) + ] + expect(names(agentRows, 'agent', 'desc')).toEqual(['permission', 'working', 'idle']) + }) + + it('breaks equal blocker counts by the worst blocker', () => { + const rows = [ + makeNamedFacets('softest', { candidate: { blockers: ['git-status-error'] } }), + makeNamedFacets('hardest', { candidate: { blockers: ['main-worktree'] } }) + ] + expect(names(rows, 'blocker-count', 'desc')).toEqual(['hardest', 'softest']) + }) +}) + +describe('tie-breaks', () => { + it('falls back to activity, repo, name, then worktree id', () => { + const rows = [ + makeNamedFacets('zeta', { candidate: { lastActivityAt: FACET_NOW - DAY } }), + makeNamedFacets('alpha', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } }) + ] + // Same size on both sides, so only the tie-break chain can order them. + expect(names(rows, 'size', 'desc')).toEqual(['alpha', 'zeta']) + }) + + it('keeps tie-broken rows in the same order when direction flips', () => { + const rows = [ + makeNamedFacets('b', { candidate: { lastActivityAt: FACET_NOW - DAY } }), + makeNamedFacets('a', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } }) + ] + expect(names(rows, 'git', 'asc')).toEqual(names(rows, 'git', 'desc')) + }) + + it('does not mutate the input array', () => { + const rows = [makeNamedFacets('b'), makeNamedFacets('a')] + const before = rows.map((row) => row.displayName) + sortWorkspaceCleanupFacets(rows, { field: 'name', direction: 'asc' }) + expect(rows.map((row) => row.displayName)).toEqual(before) + }) +}) + +describe('text fields', () => { + it('sorts by name, repo, path, branch, and host', () => { + const rows = [makeNamedFacets('charlie'), makeNamedFacets('alpha'), makeNamedFacets('bravo')] + expect(names(rows, 'name', 'asc')).toEqual(['alpha', 'bravo', 'charlie']) + expect(names(rows, 'path', 'desc')).toEqual(['charlie', 'bravo', 'alpha']) + expect(names(rows, 'branch', 'asc')).toEqual(['alpha', 'bravo', 'charlie']) + + const hostRows = [ + makeNamedFacets('remote', { worktree: { hostId: 'ssh:builder' } }), + makeNamedFacets('here', { worktree: { hostId: 'local' } }) + ] + expect(names(hostRows, 'host', 'asc')).toEqual(['here', 'remote']) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts new file mode 100644 index 00000000000..50787aa6bd9 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts @@ -0,0 +1,190 @@ +import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' +import { + getWorkspaceCleanupAgentRank, + getWorkspaceCleanupBlockerSeverity, + getWorkspaceCleanupGitRank, + getWorkspaceCleanupReviewRank, + getWorkspaceCleanupTierRank +} from '../../../../shared/workspace-cleanup-facet-rankings' +import type { + WorkspaceCleanupSortDirectionState, + WorkspaceCleanupSortField, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' + +/** + * Fields whose value can be absent. Absent rows always sink to the bottom in + * BOTH directions — flipping direction should reorder the rows that carry the + * signal, not promote the ones that carry nothing. + */ +const ABSENT_LAST_FIELDS: ReadonlySet = new Set([ + 'size', + 'last-visited', + 'created', + 'ahead', + 'behind', + 'workspace-status', + 'review', + 'ticket' +]) + +export function isWorkspaceCleanupAbsentLastSortField(field: WorkspaceCleanupSortField): boolean { + return ABSENT_LAST_FIELDS.has(field) +} + +export function sortWorkspaceCleanupFacets( + facets: readonly WorkspaceCleanupFacets[], + sort: WorkspaceCleanupSortState +): WorkspaceCleanupFacets[] { + const multiplier = sort.direction === 'asc' ? 1 : -1 + return facets.toSorted( + (left, right) => + comparePrimary(left, right, sort.field, multiplier) || compareTieBreaks(left, right) + ) +} + +export function compareWorkspaceCleanupFacets( + left: WorkspaceCleanupFacets, + right: WorkspaceCleanupFacets, + field: WorkspaceCleanupSortField, + direction: WorkspaceCleanupSortDirectionState +): number { + const multiplier = direction === 'asc' ? 1 : -1 + return comparePrimary(left, right, field, multiplier) || compareTieBreaks(left, right) +} + +function comparePrimary( + left: WorkspaceCleanupFacets, + right: WorkspaceCleanupFacets, + field: WorkspaceCleanupSortField, + multiplier: number +): number { + const absence = compareAbsence(left, right, field) + if (absence !== 0) { + return absence + } + return compareNatural(left, right, field) * multiplier +} + +/** Non-zero only when exactly one side is absent for an absent-last field. */ +function compareAbsence( + left: WorkspaceCleanupFacets, + right: WorkspaceCleanupFacets, + field: WorkspaceCleanupSortField +): number { + if (!ABSENT_LAST_FIELDS.has(field)) { + return 0 + } + const leftAbsent = isAbsent(left, field) + const rightAbsent = isAbsent(right, field) + if (leftAbsent === rightAbsent) { + return 0 + } + return leftAbsent ? 1 : -1 +} + +function isAbsent(facets: WorkspaceCleanupFacets, field: WorkspaceCleanupSortField): boolean { + if (field === 'size') { + return facets.sizeBytes === null + } + if (field === 'last-visited') { + return facets.lastVisitedAt === null + } + if (field === 'created') { + return facets.createdAt === null + } + if (field === 'ahead') { + return facets.upstreamAhead === null + } + if (field === 'behind') { + return facets.upstreamBehind === null + } + if (field === 'workspace-status') { + return facets.workspaceStatus === null + } + if (field === 'review') { + return !facets.review.hasReview + } + if (field === 'ticket') { + return facets.ticketSources.length === 0 + } + return false +} + +function compareNatural( + left: WorkspaceCleanupFacets, + right: WorkspaceCleanupFacets, + field: WorkspaceCleanupSortField +): number { + switch (field) { + case 'last-activity': + return left.lastActivityAt - right.lastActivityAt + case 'last-visited': + return (left.lastVisitedAt ?? 0) - (right.lastVisitedAt ?? 0) + case 'created': + return (left.createdAt ?? 0) - (right.createdAt ?? 0) + case 'size': + return (left.sizeBytes ?? 0) - (right.sizeBytes ?? 0) + case 'name': + return left.displayName.localeCompare(right.displayName) + case 'repo': + return left.repoName.localeCompare(right.repoName) + case 'path': + return left.path.localeCompare(right.path) + case 'host': + return left.hostId.localeCompare(right.hostId) + case 'workspace-status': + return (left.workspaceStatus ?? '').localeCompare(right.workspaceStatus ?? '') + case 'agent': + return ( + getWorkspaceCleanupAgentRank(left.agentState) - + getWorkspaceCleanupAgentRank(right.agentState) + ) + case 'git': + return getWorkspaceCleanupGitRank(left.gitState) - getWorkspaceCleanupGitRank(right.gitState) + case 'ahead': + return (left.upstreamAhead ?? 0) - (right.upstreamAhead ?? 0) + case 'behind': + return (left.upstreamBehind ?? 0) - (right.upstreamBehind ?? 0) + case 'branch': + return left.branch.localeCompare(right.branch) + case 'review': + return ( + getWorkspaceCleanupReviewRank(left.reviewState) - + getWorkspaceCleanupReviewRank(right.reviewState) || + (left.review.label ?? '').localeCompare(right.review.label ?? '') + ) + case 'ticket': + return ( + left.ticketSources.length - right.ticketSources.length || + (left.ticketSources[0] ?? '').localeCompare(right.ticketSources[0] ?? '') + ) + case 'local-context': + return left.localContextCount - right.localContextCount + case 'tier': + return getWorkspaceCleanupTierRank(left.tier) - getWorkspaceCleanupTierRank(right.tier) + case 'blocker-count': + return ( + left.blockerCount - right.blockerCount || + getMaxBlockerSeverity(left) - getMaxBlockerSeverity(right) + ) + } +} + +/** Direction-independent so a flipped sort never reshuffles equal rows. */ +function compareTieBreaks(left: WorkspaceCleanupFacets, right: WorkspaceCleanupFacets): number { + return ( + left.lastActivityAt - right.lastActivityAt || + left.repoName.localeCompare(right.repoName) || + left.displayName.localeCompare(right.displayName) || + left.worktreeId.localeCompare(right.worktreeId) + ) +} + +function getMaxBlockerSeverity(facets: WorkspaceCleanupFacets): number { + let max = 0 + for (const blocker of facets.blockers) { + max = Math.max(max, getWorkspaceCleanupBlockerSeverity(blocker)) + } + return max +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet.test.fixture.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet.test.fixture.ts new file mode 100644 index 00000000000..07e38b48141 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet.test.fixture.ts @@ -0,0 +1,126 @@ +import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' +import { + buildWorkspaceCleanupFacets, + type WorkspaceCleanupFacetSources, + type WorkspaceCleanupFacets, + type WorkspaceCleanupWorktreeFacts +} from './workspace-cleanup-facets' +import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' + +export const FACET_NOW = 1_700_000_000_000 +export const DAY = 24 * 60 * 60 * 1000 + +export function makeFacetCandidate( + overrides: Partial = {} +): WorkspaceCleanupCandidate { + return { + worktreeId: 'repo-1::/repo/alpha', + repoId: 'repo-1', + repoName: 'Repo', + connectionId: null, + displayName: 'alpha', + branch: 'refs/heads/alpha', + path: '/repo/alpha', + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: FACET_NOW - 40 * DAY, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: FACET_NOW }, + fingerprint: 'fingerprint', + ...overrides + } +} + +export function makeWorktreeFacts( + overrides: Partial = {} +): WorkspaceCleanupWorktreeFacts { + return { + id: 'repo-1::/repo/alpha', + branch: 'refs/heads/alpha', + comment: '', + isArchived: false, + isPinned: false, + isUnread: false, + linkedIssue: null, + linkedLinearIssue: null, + ...overrides + } +} + +export function makeReviewInfo( + overrides: Partial = {} +): WorkspaceCleanupReviewInfo { + return { + hasReview: true, + label: 'PR #42', + state: 'open', + provider: 'github', + title: 'Alpha cleanup', + ...overrides + } +} + +export type FacetFixtureInput = { + candidate?: Partial + worktree?: Partial | null + sizeBytes?: number + lastVisitedAt?: number + agentStatus?: LiveAgentWorktreeStatus + review?: Partial + dismissed?: boolean +} + +/** Builds through the real facet builder so tests exercise the production join. */ +export function makeFacets(input: FacetFixtureInput = {}): WorkspaceCleanupFacets { + const candidate = makeFacetCandidate(input.candidate) + const id = candidate.worktreeId + const sources: WorkspaceCleanupFacetSources = { + workspaceStatuses: cloneDefaultWorkspaceStatuses(), + worktreeById: + input.worktree === null + ? new Map() + : new Map([[id, makeWorktreeFacts({ id, ...input.worktree })]]), + sizeBytesByWorktreeId: + input.sizeBytes === undefined ? new Map() : new Map([[id, input.sizeBytes]]), + lastVisitedAtByWorktreeId: + input.lastVisitedAt === undefined ? {} : { [id]: input.lastVisitedAt }, + liveAgentStatusByWorktreeId: + input.agentStatus === undefined ? new Map() : new Map([[id, input.agentStatus]]), + reviewInfoByWorktreeId: + input.review === undefined ? new Map() : new Map([[id, makeReviewInfo(input.review)]]), + dismissedWorktreeIds: new Set(input.dismissed ? [id] : []) + } + return buildWorkspaceCleanupFacets(candidate, sources) +} + +/** Distinct worktree/display identity so tie-break ordering is observable. */ +export function makeNamedFacets( + name: string, + input: FacetFixtureInput = {} +): WorkspaceCleanupFacets { + return makeFacets({ + ...input, + candidate: { + worktreeId: `repo-1::/repo/${name}`, + displayName: name, + path: `/repo/${name}`, + branch: `refs/heads/${name}`, + ...input.candidate + }, + worktree: + input.worktree === null + ? null + : { id: `repo-1::/repo/${name}`, branch: `refs/heads/${name}`, ...input.worktree } + }) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts new file mode 100644 index 00000000000..7207931377f --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -0,0 +1,239 @@ +import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/types' +import { getWorkspaceStatus } from '../../../../shared/workspace-statuses' +import { + canSelectWorkspaceCleanupCandidate, + type WorkspaceCleanupBlocker, + type WorkspaceCleanupCandidate, + type WorkspaceCleanupTier +} from '../../../../shared/workspace-cleanup' +import { getWorkspaceCleanupGitState } from './workspace-cleanup-filter-sort' +import type { + WorkspaceCleanupAgentState, + WorkspaceCleanupGitState, + WorkspaceCleanupReviewState, + WorkspaceCleanupTicketSource +} from '../../../../shared/workspace-cleanup-filter-model' +import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' +import { + getWorkspaceCleanupCandidateHostId, + getWorkspaceCleanupHostIdentity, + type WorkspaceCleanupWorktreeFacts +} from './workspace-cleanup-host-identity' +export type { WorkspaceCleanupWorktreeFacts } from './workspace-cleanup-host-identity' + +export type WorkspaceCleanupFacetSources = { + worktreeById?: ReadonlyMap + workspaceStatuses?: readonly WorkspaceStatusDefinition[] + /** Absent entry = the space scan never ran for that worktree. */ + sizeBytesByWorktreeId?: ReadonlyMap + lastVisitedAtByWorktreeId?: Readonly> + liveAgentStatusByWorktreeId?: ReadonlyMap + reviewInfoByWorktreeId?: ReadonlyMap + dismissedWorktreeIds?: ReadonlySet +} + +export type WorkspaceCleanupFacets = { + candidate: WorkspaceCleanupCandidate + worktreeId: string + repoId: string + repoName: string + displayName: string + path: string + branch: string + hostId: ExecutionHostId + tier: WorkspaceCleanupTier + blockers: readonly WorkspaceCleanupBlocker[] + blockerCount: number + isDismissed: boolean + isSelectable: boolean + /** Background signal: ambient PTY/agent churn bumps this without a human. */ + lastActivityAt: number + createdAt: number | null + /** Honest "user opened it" signal; null when Orca never recorded a visit. */ + lastVisitedAt: number | null + sizeBytes: number | null + workspaceStatus: string | null + workspaceStatusLabel: string | null + isArchived: boolean + isPinned: boolean + isUnread: boolean + hasComment: boolean + agentState: WorkspaceCleanupAgentState + retainedDoneAgentCount: number + gitState: WorkspaceCleanupGitState + upstreamAhead: number | null + upstreamBehind: number | null + isPrunable: boolean + isLocked: boolean + review: WorkspaceCleanupReviewInfo + reviewState: WorkspaceCleanupReviewState | null + ticketSources: readonly WorkspaceCleanupTicketSource[] + localContextCount: number + hasLocalContext: boolean + isCompletelyEmpty: boolean + searchText: string +} + +const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = { + hasReview: false, + label: null, + state: null, + provider: null, + title: null +} + +export function buildWorkspaceCleanupFacets( + candidate: WorkspaceCleanupCandidate, + sources: WorkspaceCleanupFacetSources = {} +): WorkspaceCleanupFacets { + const hostIdentity = getWorkspaceCleanupHostIdentity( + getWorkspaceCleanupCandidateHostId(candidate), + candidate.worktreeId + ) + const worktree = + sources.worktreeById?.get(hostIdentity) ?? + sources.worktreeById?.get(candidate.worktreeId) ?? + null + const review = + sources.reviewInfoByWorktreeId?.get(hostIdentity) ?? + sources.reviewInfoByWorktreeId?.get(candidate.worktreeId) ?? + EMPTY_REVIEW_INFO + const ticketSources = getTicketSources(worktree) + const localContextCount = getLocalContextCount(candidate) + const hasComment = (worktree?.comment ?? '').trim().length > 0 + const branch = getBranchDisplayName(worktree?.branch ?? candidate.branch) + const facets: Omit = { + candidate, + worktreeId: candidate.worktreeId, + repoId: candidate.repoId, + repoName: candidate.repoName, + displayName: candidate.displayName, + path: candidate.path, + branch, + hostId: worktree?.hostId ?? getWorkspaceCleanupCandidateHostId(candidate), + tier: candidate.tier, + blockers: candidate.blockers, + blockerCount: candidate.blockers.length, + isDismissed: + (sources.dismissedWorktreeIds?.has(candidate.worktreeId) ?? false) || + candidate.blockers.includes('dismissed'), + isSelectable: canSelectWorkspaceCleanupCandidate(candidate), + lastActivityAt: candidate.lastActivityAt, + createdAt: toFiniteOrNull(worktree?.createdAt ?? candidate.createdAt), + lastVisitedAt: toFiniteOrNull(sources.lastVisitedAtByWorktreeId?.[candidate.worktreeId]), + sizeBytes: toFiniteOrNull( + sources.sizeBytesByWorktreeId?.get(hostIdentity) ?? + sources.sizeBytesByWorktreeId?.get(candidate.worktreeId) + ), + workspaceStatus: normalizeStatus(worktree?.workspaceStatus), + workspaceStatusLabel: getWorkspaceStatusLabel(worktree, sources.workspaceStatuses), + isArchived: worktree?.isArchived ?? candidate.reasons.includes('archived'), + isPinned: worktree?.isPinned ?? candidate.blockers.includes('pinned'), + isUnread: worktree?.isUnread ?? false, + hasComment, + agentState: sources.liveAgentStatusByWorktreeId?.get(candidate.worktreeId) ?? 'idle', + retainedDoneAgentCount: candidate.localContext.retainedDoneAgentCount, + gitState: getWorkspaceCleanupGitState(candidate), + upstreamAhead: toFiniteOrNull(candidate.git.upstreamAhead), + upstreamBehind: toFiniteOrNull(candidate.git.upstreamBehind), + isPrunable: worktree?.prunable ?? false, + isLocked: worktree?.locked ?? false, + review, + reviewState: review.hasReview ? (review.state ?? 'unknown') : null, + ticketSources, + localContextCount, + hasLocalContext: localContextCount > 0, + isCompletelyEmpty: + localContextCount === 0 && !review.hasReview && ticketSources.length === 0 && !hasComment + } + return { ...facets, searchText: buildSearchText(facets) } +} + +export function buildWorkspaceCleanupFacetList( + candidates: readonly WorkspaceCleanupCandidate[], + sources: WorkspaceCleanupFacetSources = {} +): WorkspaceCleanupFacets[] { + return candidates.map((candidate) => buildWorkspaceCleanupFacets(candidate, sources)) +} + +export function countWorkspaceCleanupMeasuredRows(rows: readonly WorkspaceCleanupFacets[]): number { + return rows.reduce((count, row) => count + (row.sizeBytes === null ? 0 : 1), 0) +} + +function getLocalContextCount(candidate: WorkspaceCleanupCandidate): number { + const context = candidate.localContext + return ( + context.terminalTabCount + + context.cleanEditorTabCount + + context.browserTabCount + + context.diffCommentCount + + context.retainedDoneAgentCount + ) +} + +function getTicketSources( + worktree: WorkspaceCleanupWorktreeFacts | null +): WorkspaceCleanupTicketSource[] { + if (!worktree) { + return [] + } + const sources: WorkspaceCleanupTicketSource[] = [] + if (worktree.linkedWorkItem != null) { + sources.push('work-item') + } + if ((worktree.linkedLinearIssue ?? '').length > 0) { + sources.push('linear') + } + if (worktree.linkedIssue != null) { + sources.push('issue') + } + return sources +} + +function buildSearchText(facets: Omit): string { + return [ + facets.displayName, + facets.repoName, + facets.branch, + facets.path, + facets.hostId, + facets.workspaceStatus, + facets.workspaceStatusLabel, + facets.review.label, + facets.review.title, + facets.review.provider, + facets.gitState, + facets.tier, + ...facets.ticketSources, + ...facets.blockers + ] + .filter((value): value is string => Boolean(value)) + .join(' ') + .toLowerCase() +} + +function normalizeStatus(status: string | undefined): string | null { + const trimmed = (status ?? '').trim() + return trimmed.length > 0 ? trimmed : null +} + +function getWorkspaceStatusLabel( + worktree: WorkspaceCleanupWorktreeFacts | null, + statuses: readonly WorkspaceStatusDefinition[] | undefined +): string | null { + if (!worktree || !statuses?.length) { + return null + } + const statusId = getWorkspaceStatus({ workspaceStatus: worktree.workspaceStatus }, statuses) + return statuses.find((status) => status.id === statusId)?.label ?? statusId +} + +function toFiniteOrNull(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function getBranchDisplayName(branch: string): string { + return branch.replace(/^refs\/heads\//, '') || 'HEAD' +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx new file mode 100644 index 00000000000..8575c3f710c --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx @@ -0,0 +1,44 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar' + +afterEach(cleanup) + +describe('WorkspaceCleanupFilterBar', () => { + it('keeps size measurement out of the browse controls', () => { + render( + + ) + + expect(screen.queryByRole('button', { name: 'Scan' })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx new file mode 100644 index 00000000000..5cb4e389af5 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { Loader2, RotateCcw, Search, SlidersHorizontal } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { ScrollArea } from '@/components/ui/scroll-area' +import { translate } from '@/i18n/i18n' +import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets' +import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets' +import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model' + +export type WorkspaceCleanupGitEvidenceProgress = { + pendingCount: number + totalCount: number +} + +export function WorkspaceCleanupFilterBar({ + facetProps, + facetPanelOpen, + onFacetPanelOpenChange, + activeFacetGroupCount, + matchedCount, + hasActiveFilters, + gitEvidence, + onQueryChange, + onClearFilters +}: { + facetProps: WorkspaceCleanupFacetGroupProps + facetPanelOpen: boolean + onFacetPanelOpenChange: (open: boolean) => void + activeFacetGroupCount: number + matchedCount: number + hasActiveFilters: boolean + gitEvidence: WorkspaceCleanupGitEvidenceProgress + onQueryChange: (query: string) => void + onClearFilters: () => void +}): React.JSX.Element { + const { filters, totalCount } = facetProps + return ( +
+
+ + onQueryChange(event.target.value)} + placeholder={translate( + 'components.workspace.cleanup.browse.searchPlaceholder', + 'Search name, repo, branch, path, host' + )} + aria-label={translate( + 'components.workspace.cleanup.browse.searchLabel', + 'Search workspaces' + )} + className="h-8 pl-8 text-xs" + /> +
+ + + + + + + + + + +
+ + {translate( + 'components.workspace.cleanup.browse.showingCount', + 'Showing {{value0}} of {{value1}}', + { value0: matchedCount, value1: totalCount } + )} + + +
+
+
+ + + {translate( + 'components.workspace.cleanup.browse.showingCount', + 'Showing {{value0}} of {{value1}}', + { value0: matchedCount, value1: totalCount } + )} + + + {gitEvidence.pendingCount > 0 ? ( + + + {translate( + 'components.workspace.cleanup.browse.checkingGit', + 'Checking git status: {{value0}} left', + { value0: gitEvidence.pendingCount } + )} + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts index ff597290bfd..e839de0c288 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts @@ -1,4 +1,5 @@ import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { WorkspaceCleanupGitState } from '../../../../shared/workspace-cleanup-filter-model' import type { WorkspaceCleanupContextFilter, WorkspaceCleanupFilters, @@ -94,20 +95,35 @@ export function sortWorkspaceCleanupCandidates( }) } +const GIT_STATE_LABELS: Record = { + unpushed: 'Unpushed', + unknown: 'Unknown', + clean: 'Clean', + dirty: 'Dirty' +} + export function getWorkspaceCleanupGitLabel(candidate: WorkspaceCleanupCandidate): string { + return GIT_STATE_LABELS[getWorkspaceCleanupGitState(candidate)] +} + +/** Unpushed outranks unknown: a lost commit is worse than an unreadable status. */ +export function getWorkspaceCleanupGitState( + candidate: WorkspaceCleanupCandidate +): WorkspaceCleanupGitState { if (hasUnpushedCommits(candidate)) { - return 'Unpushed' + return 'unpushed' } if (isGitStatusUnknown(candidate)) { - return 'Unknown' + return 'unknown' } if (candidate.git.clean === true) { - return 'Clean' + return 'clean' } - if (candidate.git.clean === false) { - return 'Dirty' - } - return 'Unknown' + return candidate.git.clean === false ? 'dirty' : 'unknown' +} + +export function hasWorkspaceCleanupUnpushedCommits(candidate: WorkspaceCleanupCandidate): boolean { + return hasUnpushedCommits(candidate) } function compareWorkspaceCleanupCandidates( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx new file mode 100644 index 00000000000..bbbeaac5e73 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createDefaultWorkspaceCleanupFilterState, + DEFAULT_WORKSPACE_CLEANUP_SORT, + type WorkspaceCleanupFilterState +} from '../../../../shared/workspace-cleanup-filter-model' +import { CandidateRow } from './workspace-cleanup-candidate-row' +import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' +import { FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture' +import { runWorkspaceCleanupQuery } from './workspace-cleanup-query' +import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +// One row per bucket the retired tab partition used to hide behind a tab. +const FLEET: WorkspaceCleanupFacets[] = [ + makeNamedFacets('ready-one', { candidate: { tier: 'ready' }, sizeBytes: 4 * 1024 * 1024 }), + makeNamedFacets('needs-review', { candidate: { tier: 'review' } }), + makeNamedFacets('protected-one', { + candidate: { tier: 'protected', blockers: ['pinned'], selectedByDefault: false } + }), + makeNamedFacets('ignored-one', { dismissed: true }) +] + +function renderRows(rows: readonly WorkspaceCleanupFacets[]): void { + act(() => + root?.render( + ( + + )} + /> + ) + ) +} + +function renderedNames(): string[] { + // Buttons share `text-sm font-medium`, so scope to the row's name span. + return [...(container?.querySelectorAll('span.truncate.text-sm.font-medium') ?? [])].map( + (node) => node.textContent ?? '' + ) +} + +function query(filters: WorkspaceCleanupFilterState = createDefaultWorkspaceCleanupFilterState()) { + return runWorkspaceCleanupQuery( + FLEET, + { filters, sort: DEFAULT_WORKSPACE_CLEANUP_SORT }, + FACET_NOW + ) +} + +describe('workspace cleanup flat list', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('renders every tier in one list rather than per-tab groups', () => { + const result = query() + + renderRows(result.rows) + + expect(result.totalCount).toBe(FLEET.length) + expect(result.matchedCount).toBe(FLEET.length) + expect(renderedNames()).toEqual( + expect.arrayContaining(['ready-one', 'needs-review', 'protected-one', 'ignored-one']) + ) + }) + + it('switches the visible rows when filters are applied', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.safety.tiers = ['ready'] + filters.safety.dismissed = 'exclude' + renderRows(query(filters).rows) + expect(renderedNames()).toEqual(['ready-one']) + + filters.safety.tiers = [] + filters.safety.dismissed = 'only' + renderRows(query(filters).rows) + expect(renderedNames()).toEqual(['ignored-one']) + + filters.safety.dismissed = 'any' + filters.safety.tiers = ['protected'] + renderRows(query(filters).rows) + expect(renderedNames()).toEqual(['protected-one']) + }) + + it('reports only the rows the user could actually queue for deletion', () => { + expect(query().selectableWorktreeIds).not.toContain('repo-1::/repo/protected-one') + }) + + it('shows status and disk-size facts without expanding a row', () => { + renderRows(query().rows) + + expect(container?.textContent).toContain('In progress') + expect(container?.textContent).toContain('4.00 MB') + expect(container?.textContent).toContain('Not measured') + const sizedRows = [...(container?.querySelectorAll('[aria-label^="Size on disk"]') ?? [])] + expect(sizedRows).toHaveLength(FLEET.length) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-freshness.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-freshness.tsx new file mode 100644 index 00000000000..7a78c9fd597 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-freshness.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { Loader2 } from 'lucide-react' +import { useNow } from '@/components/dashboard/useNow' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup' +import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' + +/** + * Fixed-slot freshness line: "Updated 2h ago" for the data on screen plus a + * non-blocking "Refreshing" affordance while a background rescan streams. + */ +export function WorkspaceCleanupFreshness({ + scannedAt, + refreshing, + progress +}: { + scannedAt: number | null + refreshing: boolean + progress: WorkspaceCleanupScanProgress | null +}): React.JSX.Element | null { + const now = useNow(30_000) + if (scannedAt === null && !refreshing) { + return null + } + return ( + + {scannedAt !== null ? ( + + {translate('components.workspace.cleanup.browse.updatedAgo', 'Updated {{value0}}', { + value0: formatWorkspaceCleanupRelativeTime(scannedAt, now).toLowerCase() + })} + + ) : null} + {refreshing ? ( + + + ) : null} + + ) +} + +function formatWorkspaceCleanupRefreshingLabel( + progress: WorkspaceCleanupScanProgress | null +): string { + if (!progress || progress.totalWorktreeCount === 0) { + return translate('components.workspace.cleanup.browse.refreshing', 'Refreshing…') + } + return translate( + 'components.workspace.cleanup.browse.refreshingProgress', + 'Refreshing {{value0}}/{{value1}}', + { value0: progress.scannedWorktreeCount, value1: progress.totalWorktreeCount } + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts new file mode 100644 index 00000000000..bfcc1a087a4 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture' +import { + applyWorkspaceCleanupGitEvidence, + hasWorkspaceCleanupGitEvidence, + needsWorkspaceCleanupGitEvidence, + selectWorkspaceCleanupGitEvidenceTargets +} from './workspace-cleanup-git-evidence' + +const CLEAN_SORT = { field: 'name', direction: 'asc' } as const + +function deferredCandidate(worktreeId: string) { + return makeFacetCandidate({ + worktreeId, + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) +} + +describe('needsWorkspaceCleanupGitEvidence', () => { + it('stays off for a filter and sort that never read git', () => { + expect( + needsWorkspaceCleanupGitEvidence(createDefaultWorkspaceCleanupFilterState(), CLEAN_SORT) + ).toBe(false) + }) + + it.each([ + [ + 'git state', + (f: ReturnType) => { + f.git.states = ['dirty'] + } + ], + [ + 'ahead threshold', + (f: ReturnType) => { + f.git.minAhead = 1 + } + ], + [ + 'behind threshold', + (f: ReturnType) => { + f.git.minBehind = 1 + } + ], + [ + 'git-derived blocker', + (f: ReturnType) => { + f.safety.blockers = ['unpushed-commits'] + } + ] + ])('turns on for a %s filter', (_label, mutate) => { + const filters = createDefaultWorkspaceCleanupFilterState() + mutate(filters) + expect(needsWorkspaceCleanupGitEvidence(filters, CLEAN_SORT)).toBe(true) + }) + + it.each(['git', 'ahead', 'behind'] as const)('turns on for the %s sort', (field) => { + expect( + needsWorkspaceCleanupGitEvidence(createDefaultWorkspaceCleanupFilterState(), { + field, + direction: 'asc' + }) + ).toBe(true) + }) + + it('ignores a blocker filter that does not depend on git', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.safety.blockers = ['pinned'] + expect(needsWorkspaceCleanupGitEvidence(filters, CLEAN_SORT)).toBe(false) + }) +}) + +describe('selectWorkspaceCleanupGitEvidenceTargets', () => { + it('only targets rows the host deferred', () => { + const targets = selectWorkspaceCleanupGitEvidenceTargets([ + deferredCandidate('a'), + makeFacetCandidate({ worktreeId: 'b' }) + ]) + expect(targets).toEqual(['a']) + expect(hasWorkspaceCleanupGitEvidence(makeFacetCandidate({ worktreeId: 'b' }))).toBe(true) + }) + + it('skips rows already attempted and caps the batch', () => { + const candidates = ['a', 'b', 'c'].map(deferredCandidate) + expect( + selectWorkspaceCleanupGitEvidenceTargets(candidates, { + resolvedWorktreeIds: new Set(['a']) + }) + ).toEqual(['b', 'c']) + expect(selectWorkspaceCleanupGitEvidenceTargets(candidates, { maxTargets: 2 })).toEqual([ + 'a', + 'b' + ]) + }) +}) + +describe('applyWorkspaceCleanupGitEvidence', () => { + it('replaces the deferred row with the focused re-scan result', () => { + const deferred = deferredCandidate('a') + const refreshed = makeFacetCandidate({ + worktreeId: 'a', + git: { clean: false, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 5 } + }) + const [row] = applyWorkspaceCleanupGitEvidence([deferred], new Map([['a', refreshed]])) + expect(row.git.clean).toBe(false) + expect(row.git.checkedAt).toBe(5) + }) + + it('returns the input untouched when nothing was re-scanned', () => { + const candidates = [deferredCandidate('a')] + expect(applyWorkspaceCleanupGitEvidence(candidates, new Map())).toBe(candidates) + }) + + it('merges only git facts into the current broad-scan row', () => { + const current = deferredCandidate('a') + current.displayName = 'current name' + current.blockers = ['dismissed'] + current.fingerprint = 'current-fingerprint' + const refreshed = makeFacetCandidate({ + worktreeId: 'a', + displayName: 'stale name', + blockers: ['dirty-files'], + fingerprint: 'focused-fingerprint', + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 5 } + }) + + const [row] = applyWorkspaceCleanupGitEvidence([current], new Map([['a', refreshed]])) + + expect(row.displayName).toBe('current name') + expect(row.fingerprint).toBe('current-fingerprint') + expect(row.blockers).toEqual(expect.arrayContaining(['dismissed', 'dirty-files'])) + expect(row.git).toEqual(refreshed.git) + }) + + it('keeps newer broad-scan git evidence', () => { + const current = makeFacetCandidate({ + worktreeId: 'a', + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 10 } + }) + const older = makeFacetCandidate({ + worktreeId: 'a', + git: { clean: false, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 5 } + }) + + expect(applyWorkspaceCleanupGitEvidence([current], new Map([['a', older]]))[0]).toBe(current) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts new file mode 100644 index 00000000000..24fb5de87ff --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts @@ -0,0 +1,101 @@ +import type { + WorkspaceCleanupBlocker, + WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' +import { + applyWorkspaceCleanupPolicy, + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT +} from '../../../../shared/workspace-cleanup' +import type { + WorkspaceCleanupFilterState, + WorkspaceCleanupSortField, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' + +/** + * The host defers git evidence for rows with no inactivity reason, so their + * `git.checkedAt` is null and the facet layer reads them as `unknown`. A + * git-dependent filter or sort would then quietly mislabel most of the list, + * so the dialog re-scans those rows on demand before trusting the answer. + */ +export function hasWorkspaceCleanupGitEvidence(candidate: WorkspaceCleanupCandidate): boolean { + return candidate.git.checkedAt !== null +} + +const GIT_SORT_FIELDS: ReadonlySet = new Set(['git', 'ahead', 'behind']) + +const GIT_DERIVED_BLOCKERS: ReadonlySet = new Set([ + 'dirty-files', + 'unpushed-commits', + 'git-status-error', + 'unknown-base' +]) + +/** Bounds how many worktrees one Git-evidence batch can inspect across a huge fleet. */ +export const WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS = WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT + +export function needsWorkspaceCleanupGitEvidence( + filters: WorkspaceCleanupFilterState, + sort: WorkspaceCleanupSortState +): boolean { + return ( + filters.git.states.length > 0 || + filters.git.minAhead !== null || + filters.git.minBehind !== null || + filters.safety.blockers.some((blocker) => GIT_DERIVED_BLOCKERS.has(blocker)) || + GIT_SORT_FIELDS.has(sort.field) + ) +} + +/** + * Ids still missing git evidence, capped and in list order so the rows the user + * is looking at resolve first. + */ +export function selectWorkspaceCleanupGitEvidenceTargets( + candidates: readonly WorkspaceCleanupCandidate[], + options: { + resolvedWorktreeIds?: ReadonlySet + maxTargets?: number + } = {} +): string[] { + const resolved = options.resolvedWorktreeIds ?? new Set() + const max = options.maxTargets ?? WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS + const targets: string[] = [] + for (const candidate of candidates) { + if (targets.length >= max) { + break + } + if (!hasWorkspaceCleanupGitEvidence(candidate) && !resolved.has(candidate.worktreeId)) { + targets.push(candidate.worktreeId) + } + } + return targets +} + +/** Focused re-scan results win over the deferred broad-scan row. */ +export function applyWorkspaceCleanupGitEvidence( + candidates: readonly WorkspaceCleanupCandidate[], + evidenceByWorktreeId: ReadonlyMap +): readonly WorkspaceCleanupCandidate[] { + if (evidenceByWorktreeId.size === 0) { + return candidates + } + return candidates.map((candidate) => { + const evidence = evidenceByWorktreeId.get(candidate.worktreeId) + if ( + evidence === undefined || + (candidate.git.checkedAt !== null && + candidate.git.checkedAt >= (evidence.git.checkedAt ?? Number.NEGATIVE_INFINITY)) + ) { + return candidate + } + return applyWorkspaceCleanupPolicy({ + ...candidate, + blockers: [ + ...candidate.blockers.filter((blocker) => !GIT_DERIVED_BLOCKERS.has(blocker)), + ...evidence.blockers.filter((blocker) => GIT_DERIVED_BLOCKERS.has(blocker)) + ], + git: evidence.git + }) + }) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx new file mode 100644 index 00000000000..3db2300c4c5 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx @@ -0,0 +1,209 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import { getExecutionHostLabel } from '../../../../shared/execution-host' +import { + WORKSPACE_CLEANUP_BLOCKER_MODE_VALUES, + WORKSPACE_CLEANUP_BLOCKER_VALUES, + WORKSPACE_CLEANUP_GIT_STATE_VALUES, + WORKSPACE_CLEANUP_PRESENCE_VALUES, + WORKSPACE_CLEANUP_REVIEW_STATE_VALUES, + WORKSPACE_CLEANUP_TICKET_SOURCE_VALUES, + WORKSPACE_CLEANUP_TIER_VALUES, + WORKSPACE_CLEANUP_TRI_STATE_VALUES +} from '../../../../shared/workspace-cleanup-facet-rankings' +import { getWorkspaceCleanupBlockerLabel } from './workspace-cleanup-candidate-labels' +import { + FacetCheckbox, + FacetChoice, + FacetNumberField, + FacetSection, + FacetTextField, + FacetToggleList +} from './workspace-cleanup-facet-controls' +import { + getWorkspaceCleanupBlockerModeLabel, + getWorkspaceCleanupGitStateLabel, + getWorkspaceCleanupPresenceLabel, + getWorkspaceCleanupReviewProviderLabel, + getWorkspaceCleanupReviewStateLabel, + getWorkspaceCleanupTicketSourceLabel, + getWorkspaceCleanupTierLabel, + getWorkspaceCleanupTriStateLabel +} from './workspace-cleanup-facet-labels' +import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model' + +/** Git, review/PR, tickets, location/host, and safety blockers. */ +export function WorkspaceCleanupGitReviewFacets({ + filters, + counts, + totalCount, + options, + onPatch +}: WorkspaceCleanupFacetGroupProps): React.JSX.Element { + return ( + <> + + onPatch('git', { states })} + /> + onPatch('git', { minAhead })} + /> + onPatch('git', { minBehind })} + /> + onPatch('git', { branchQuery })} + /> + onPatch('git', { prunable })} + /> + onPatch('git', { locked })} + /> + + + + onPatch('review', { presence })} + /> + onPatch('review', { states })} + /> + {options.reviewProviders.length > 0 ? ( + onPatch('review', { providers })} + /> + ) : null} + + + + onPatch('ticket', { presence })} + /> + onPatch('ticket', { sources })} + /> + + + + onPatch('location', { hostIds })} + /> + {options.repos.length > 1 ? ( + repo.id)} + selected={filters.location.repoIds} + getLabel={(id) => options.repos.find((repo) => repo.id === id)?.label ?? id} + onChange={(repoIds) => onPatch('location', { repoIds })} + /> + ) : null} + onPatch('location', { pathPrefix })} + /> + + + + onPatch('safety', { tiers })} + /> + onPatch('safety', { blockerMode })} + /> + onPatch('safety', { blockers })} + /> + onPatch('safety', { dismissed })} + /> + onPatch('safety', { selectableOnly })} + /> + + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts new file mode 100644 index 00000000000..5f01b485678 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts @@ -0,0 +1,137 @@ +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { + WorkspaceSpaceWorktree, + WorkspaceSpaceWorktreeMeasurement +} from '../../../../shared/workspace-space-types' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' + +/** Structural subset of `Worktree` kept in sync with the persisted source. */ +export type WorkspaceCleanupWorktreeFacts = Pick & + Partial< + Pick< + Worktree, + | 'repoId' + | 'workspaceStatus' + | 'isArchived' + | 'isPinned' + | 'isUnread' + | 'comment' + | 'hostId' + | 'branch' + | 'createdAt' + | 'linkedWorkItem' + | 'linkedLinearIssue' + | 'linkedIssue' + | 'locked' + | 'prunable' + > + > + +export function getWorkspaceCleanupCandidateHostId( + candidate: Pick +): ExecutionHostId { + return ( + normalizeExecutionHostId(candidate.executionHostId) ?? + (candidate.connectionId + ? toSshExecutionHostId(candidate.connectionId) + : LOCAL_EXECUTION_HOST_ID) + ) +} + +export function getWorkspaceCleanupHostIdentity(hostId: string, id: string): string { + return `${hostId}\0${id}` +} + +function countIds( + rows: readonly Row[], + include: (row: Row) => boolean = () => true +): Map { + const counts = new Map() + for (const row of rows) { + if (include(row)) { + counts.set(row.worktreeId, (counts.get(row.worktreeId) ?? 0) + 1) + } + } + return counts +} + +export function countWorkspaceCleanupCandidateIds( + candidates: readonly Pick[] +): Map { + return countIds(candidates) +} + +/** Only `ok` scans carry a trustworthy byte count; everything else stays unsized. */ +export function buildWorkspaceCleanupSizeIndex( + worktrees: + | readonly (WorkspaceSpaceWorktree | WorkspaceSpaceWorktreeMeasurement)[] + | null + | undefined, + candidates?: readonly Pick< + WorkspaceCleanupCandidate, + 'connectionId' | 'executionHostId' | 'worktreeId' + >[] +): Map { + const index = new Map() + const candidateCounts = candidates ? countWorkspaceCleanupCandidateIds(candidates) : null + const legacyCounts = countIds( + worktrees ?? [], + (entry) => !normalizeExecutionHostId(entry.executionHostId) + ) + for (const entry of worktrees ?? []) { + if (entry.status === 'ok' && Number.isFinite(entry.sizeBytes)) { + const hostId = normalizeExecutionHostId(entry.executionHostId) + if (hostId) { + index.set(getWorkspaceCleanupHostIdentity(hostId, entry.worktreeId), entry.sizeBytes) + } else if ( + legacyCounts.get(entry.worktreeId) === 1 && + (!candidateCounts || candidateCounts.get(entry.worktreeId) === 1) + ) { + index.set(entry.worktreeId, entry.sizeBytes) + } + } + } + return index +} + +export function buildWorkspaceCleanupWorktreeIndex( + worktreesByRepo: Readonly>, + repos: readonly Pick[] = [] +): Map { + const index = new Map() + const repoHostsById = new Map>() + for (const repo of repos) { + const hosts = repoHostsById.get(repo.id) ?? new Set() + hosts.add(getRepoExecutionHostId(repo)) + repoHostsById.set(repo.id, hosts) + } + const legacyCounts = new Map() + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + if (!normalizeExecutionHostId(worktree.hostId)) { + legacyCounts.set(worktree.id, (legacyCounts.get(worktree.id) ?? 0) + 1) + } + } + } + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + const explicitHostId = normalizeExecutionHostId(worktree.hostId) + const repoHosts = worktree.repoId ? repoHostsById.get(worktree.repoId) : undefined + const derivedHostId = repoHosts?.size === 1 ? [...repoHosts][0] : null + const hostId = + explicitHostId ?? (legacyCounts.get(worktree.id) === 1 ? derivedHostId : undefined) + if (hostId) { + index.set(getWorkspaceCleanupHostIdentity(hostId, worktree.id), worktree) + } + } + } + return index +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-lifecycle-facets.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-lifecycle-facets.tsx new file mode 100644 index 00000000000..a65332eabb6 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-lifecycle-facets.tsx @@ -0,0 +1,198 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import { + WORKSPACE_CLEANUP_AGENT_STATE_VALUES, + WORKSPACE_CLEANUP_IDLE_SIGNAL_VALUES, + WORKSPACE_CLEANUP_PRESENCE_VALUES, + WORKSPACE_CLEANUP_TRI_STATE_VALUES +} from '../../../../shared/workspace-cleanup-facet-rankings' +import { + FacetCheckbox, + FacetChoice, + FacetNumberField, + FacetSection, + FacetToggleList +} from './workspace-cleanup-facet-controls' +import { + getWorkspaceCleanupAgentStateLabel, + getWorkspaceCleanupIdleSignalLabel, + getWorkspaceCleanupPresenceLabel, + getWorkspaceCleanupTriStateLabel +} from './workspace-cleanup-facet-labels' +import { + fromMegabytes, + toMegabytes, + type WorkspaceCleanupFacetGroupProps +} from './workspace-cleanup-facet-panel-model' + +/** Activity, size, workspace status, agent state, and local context. */ +export function WorkspaceCleanupLifecycleFacets({ + filters, + counts, + totalCount, + options, + onPatch +}: WorkspaceCleanupFacetGroupProps): React.JSX.Element { + return ( + <> + + onPatch('activity', { idleSignal })} + /> + onPatch('activity', { idleMinDays })} + /> + onPatch('activity', { neverVisited })} + /> + + + + onPatch('size', { minBytes: fromMegabytes(megabytes) })} + /> + onPatch('size', { maxBytes: fromMegabytes(megabytes) })} + /> + onPatch('size', { includeUnsized })} + /> + + + + {options.workspaceStatuses.length > 0 ? ( + status.id)} + selected={filters.status.workspaceStatuses} + getLabel={(id) => + options.workspaceStatuses.find((status) => status.id === id)?.label ?? id + } + onChange={(workspaceStatuses) => onPatch('status', { workspaceStatuses })} + /> + ) : null} + onPatch('status', { matchStatusless })} + /> + onPatch('status', { archived })} + /> + onPatch('status', { pinned })} + /> + onPatch('status', { unread })} + /> + onPatch('status', { comment })} + /> + + + + onPatch('agent', { states })} + /> + onPatch('agent', { retainedDoneAgents })} + /> + + + + onPatch('context', { presence })} + /> + onPatch('context', { completelyEmpty })} + /> + + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx new file mode 100644 index 00000000000..5fea9257680 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import type { LucideIcon } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import type { StatusPillTone } from './workspace-cleanup-candidate-row-data' + +export function WorkspaceCleanupMetadataChip({ + icon: Icon, + label, + value, + tone = 'neutral' +}: { + icon: LucideIcon + label: string + value?: string + tone?: StatusPillTone +}): React.JSX.Element { + return ( + + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts index f6e1aab4488..2f24f47fb0c 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts @@ -16,6 +16,7 @@ import { makeReview, makeState } from './workspace-cleanup-presentation-fixtures' +import type { Repo } from '../../../../shared/repo-types' describe('workspace cleanup presentation', () => { it('finds hosted review details from renderer state', () => { @@ -34,6 +35,76 @@ describe('workspace cleanup presentation', () => { }) }) + it('host-qualifies same-id repo, worktree, and review cache joins', () => { + const base = makeState() + const localRepo = { ...base.repos[0]!, executionHostId: 'local' as const } + const remoteRepo: Repo = { + ...localRepo, + connectionId: 'builder', + executionHostId: 'ssh:builder' + } + const worktreeId = 'repo-1::/repo/alpha' + const localWorktree = { + ...base.worktreesByRepo['repo-1']![0]!, + hostId: 'local' as const, + linkedPR: 11 + } + const remoteWorktree = { + ...localWorktree, + hostId: 'ssh:builder' as const, + linkedPR: 22 + } + const localKey = getHostedReviewCacheKey('/repo', 'alpha', {}, 'repo-1', null, 'local', true) + const remoteKey = getHostedReviewCacheKey( + '/repo', + 'alpha', + {}, + 'repo-1', + 'builder', + 'ssh:builder', + true + ) + const state = makeState({ + repos: [localRepo, remoteRepo], + worktreesByRepo: { 'repo-1': [localWorktree, remoteWorktree] }, + hostedReviewCache: { + [localKey]: { data: makeReview({ number: 11, title: 'Local review' }), fetchedAt: NOW }, + [remoteKey]: { data: makeReview({ number: 22, title: 'Remote review' }), fetchedAt: NOW } + } + }) + + expect( + getWorkspaceCleanupReviewInfo( + makeCandidate({ + worktreeId, + connectionId: 'builder', + executionHostId: 'ssh:builder' + }), + state + ) + ).toMatchObject({ label: 'PR #22', title: 'Remote review' }) + }) + + it('does not guess a host for legacy same-id review joins', () => { + const base = makeState() + const duplicateRepo = { + ...base.repos[0]!, + connectionId: 'builder', + executionHostId: 'ssh:builder' as const + } + const state = makeState({ + repos: [base.repos[0]!, duplicateRepo], + worktreesByRepo: { + 'repo-1': [ + { ...base.worktreesByRepo['repo-1']![0]!, linkedPR: 11 }, + { ...base.worktreesByRepo['repo-1']![0]!, linkedPR: 22 } + ] + } + }) + + expect(getWorkspaceCleanupReviewInfo(makeCandidate(), state).hasReview).toBe(false) + }) + it('filters by time, review, git, and context', () => { const openReview: WorkspaceCleanupReviewInfo = { hasReview: true, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts index 1accef346ca..f3506b07e20 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts @@ -1,11 +1,12 @@ -import { getWorktreeMapFromState } from '@/store/selectors' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { HostedReviewInfo, HostedReviewProvider } from '../../../../shared/hosted-review' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { getWorkspaceCleanupCandidateHostId } from './workspace-cleanup-host-identity' export type WorkspaceCleanupSortKey = 'activity' | 'name' | 'repo' | 'review' | 'git' export type WorkspaceCleanupSortDirection = 'asc' | 'desc' @@ -40,6 +41,11 @@ export type WorkspaceCleanupRendererStateInputs = Pick< 'worktreesByRepo' | 'hostedReviewCache' | 'repos' | 'settings' > +export type WorkspaceCleanupReviewLookup = { + reposById: ReadonlyMap + worktreesByRepoAndId: ReadonlyMap +} + export { filterWorkspaceCleanupCandidates, getWorkspaceCleanupGitLabel, @@ -50,10 +56,11 @@ export { export function getWorkspaceCleanupReviewInfo( candidate: WorkspaceCleanupCandidate, - state: WorkspaceCleanupRendererStateInputs + state: WorkspaceCleanupRendererStateInputs, + lookup: WorkspaceCleanupReviewLookup = buildWorkspaceCleanupReviewLookup(state) ): WorkspaceCleanupReviewInfo { - const worktree = getWorktreeMapFromState(state).get(candidate.worktreeId) ?? null - const repo = state.repos.find((entry) => entry.id === candidate.repoId) ?? null + const repo = findCandidateRepo(candidate, lookup) + const worktree = findCandidateWorktree(candidate, repo, lookup) const hostedReview = getCachedHostedReview(candidate, worktree, repo, state) if (hostedReview) { return { @@ -85,6 +92,27 @@ export function getWorkspaceCleanupReviewInfo( } } +export function buildWorkspaceCleanupReviewLookup( + state: Pick +): WorkspaceCleanupReviewLookup { + const reposById = new Map() + for (const repo of state.repos) { + const matches = reposById.get(repo.id) ?? [] + matches.push(repo) + reposById.set(repo.id, matches) + } + const worktreesByRepoAndId = new Map() + for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { + for (const worktree of worktrees) { + const key = `${repoId}\0${worktree.id}` + const matches = worktreesByRepoAndId.get(key) ?? [] + matches.push(worktree) + worktreesByRepoAndId.set(key, matches) + } + } + return { reposById, worktreesByRepoAndId } +} + function getCachedHostedReview( candidate: WorkspaceCleanupCandidate, worktree: Worktree | null, @@ -99,11 +127,43 @@ function getCachedHostedReview( getBranchDisplayName(worktree?.branch ?? candidate.branch), state.settings, repo.id, - repo.connectionId + repo.connectionId, + repo.executionHostId, + true ) return state.hostedReviewCache[cacheKey]?.data ?? null } +function findCandidateRepo( + candidate: WorkspaceCleanupCandidate, + lookup: WorkspaceCleanupReviewLookup +): Repo | null { + const matches = lookup.reposById.get(candidate.repoId) ?? [] + if (!candidate.executionHostId && !candidate.connectionId) { + return matches.length === 1 ? matches[0] : null + } + const hostId = getWorkspaceCleanupCandidateHostId(candidate) + const hostMatches = matches.filter((repo) => getRepoExecutionHostId(repo) === hostId) + return hostMatches.length === 1 ? hostMatches[0] : null +} + +function findCandidateWorktree( + candidate: WorkspaceCleanupCandidate, + repo: Repo | null, + lookup: WorkspaceCleanupReviewLookup +): Worktree | null { + const matches = + lookup.worktreesByRepoAndId.get(`${candidate.repoId}\0${candidate.worktreeId}`) ?? [] + const hostId = getWorkspaceCleanupCandidateHostId(candidate) + const hostMatches = matches.filter((worktree) => worktree.hostId === hostId) + if (hostMatches.length === 1) { + return hostMatches[0] + } + const legacyMatches = matches.filter((worktree) => !worktree.hostId) + const repoMatches = lookup.reposById.get(candidate.repoId) ?? [] + return legacyMatches.length === 1 && repo && repoMatches.length === 1 ? legacyMatches[0] : null +} + function getLinkedReviewFallback(worktree: Worktree | null): { label: string provider: HostedReviewProvider diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts new file mode 100644 index 00000000000..6a722d15ab0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts @@ -0,0 +1,142 @@ +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + buildWorkspaceCleanupFacetList, + type WorkspaceCleanupFacetSources, + type WorkspaceCleanupFacets +} from './workspace-cleanup-facets' +import { + matchesWorkspaceCleanupActivity, + matchesWorkspaceCleanupAgent, + matchesWorkspaceCleanupContext, + matchesWorkspaceCleanupGit, + matchesWorkspaceCleanupLocation, + matchesWorkspaceCleanupReview, + matchesWorkspaceCleanupSafety, + matchesWorkspaceCleanupSize, + matchesWorkspaceCleanupStatus, + matchesWorkspaceCleanupTicket +} from './workspace-cleanup-facet-matchers' +import { sortWorkspaceCleanupFacets } from './workspace-cleanup-facet-sort' +import type { + WorkspaceCleanupFilterState, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' + +export type WorkspaceCleanupQuery = { + filters: WorkspaceCleanupFilterState + sort: WorkspaceCleanupSortState +} + +export type WorkspaceCleanupQueryResult = { + /** Sorted rows that passed every facet. */ + rows: WorkspaceCleanupFacets[] + /** Rows the user could actually queue for deletion, for the select-all affordance. */ + selectableWorktreeIds: string[] + totalCount: number + matchedCount: number +} + +export function matchesWorkspaceCleanupFilters( + facets: WorkspaceCleanupFacets, + filters: WorkspaceCleanupFilterState, + now: number = Date.now() +): boolean { + const query = filters.query.trim().toLowerCase() + return matchesWorkspaceCleanupFiltersWithQuery(facets, filters, now, query) +} + +function matchesWorkspaceCleanupFiltersWithQuery( + facets: WorkspaceCleanupFacets, + filters: WorkspaceCleanupFilterState, + now: number, + query: string +): boolean { + if (query && !facets.searchText.includes(query)) { + return false + } + return ( + matchesWorkspaceCleanupSafety(facets, filters.safety) && + matchesWorkspaceCleanupActivity(facets, filters.activity, now) && + matchesWorkspaceCleanupSize(facets, filters.size) && + matchesWorkspaceCleanupStatus(facets, filters.status) && + matchesWorkspaceCleanupAgent(facets, filters.agent) && + matchesWorkspaceCleanupGit(facets, filters.git) && + matchesWorkspaceCleanupReview(facets, filters.review) && + matchesWorkspaceCleanupTicket(facets, filters.ticket) && + matchesWorkspaceCleanupContext(facets, filters.context) && + matchesWorkspaceCleanupLocation(facets, filters.location) + ) +} + +export function filterWorkspaceCleanupFacets( + facets: readonly WorkspaceCleanupFacets[], + filters: WorkspaceCleanupFilterState, + now: number = Date.now() +): WorkspaceCleanupFacets[] { + const query = filters.query.trim().toLowerCase() + return facets.filter((entry) => + matchesWorkspaceCleanupFiltersWithQuery(entry, filters, now, query) + ) +} + +export function runWorkspaceCleanupQuery( + facets: readonly WorkspaceCleanupFacets[], + query: WorkspaceCleanupQuery, + now: number = Date.now() +): WorkspaceCleanupQueryResult { + const matched = filterWorkspaceCleanupFacets(facets, query.filters, now) + const rows = sortWorkspaceCleanupFacets(matched, query.sort) + return { + rows, + selectableWorktreeIds: rows.filter((row) => row.isSelectable).map((row) => row.worktreeId), + totalCount: facets.length, + matchedCount: rows.length + } +} + +/** One-shot convenience for callers holding raw scan candidates. */ +export function queryWorkspaceCleanupCandidates( + candidates: readonly WorkspaceCleanupCandidate[], + query: WorkspaceCleanupQuery, + sources: WorkspaceCleanupFacetSources = {}, + now: number = Date.now() +): WorkspaceCleanupQueryResult { + return runWorkspaceCleanupQuery(buildWorkspaceCleanupFacetList(candidates, sources), query, now) +} + +/** + * Per-facet-group match counts for badge/count rendering. Each entry counts the + * rows that would survive if only that group were applied, so a UI can show how + * much each facet is costing without re-running the whole pipeline per group. + */ +export function countWorkspaceCleanupFacetMatches( + facets: readonly WorkspaceCleanupFacets[], + filters: WorkspaceCleanupFilterState, + now: number = Date.now() +): Record, number> { + return { + activity: count(facets, (row) => matchesWorkspaceCleanupActivity(row, filters.activity, now)), + size: count(facets, (row) => matchesWorkspaceCleanupSize(row, filters.size)), + status: count(facets, (row) => matchesWorkspaceCleanupStatus(row, filters.status)), + agent: count(facets, (row) => matchesWorkspaceCleanupAgent(row, filters.agent)), + git: count(facets, (row) => matchesWorkspaceCleanupGit(row, filters.git)), + review: count(facets, (row) => matchesWorkspaceCleanupReview(row, filters.review)), + ticket: count(facets, (row) => matchesWorkspaceCleanupTicket(row, filters.ticket)), + context: count(facets, (row) => matchesWorkspaceCleanupContext(row, filters.context)), + location: count(facets, (row) => matchesWorkspaceCleanupLocation(row, filters.location)), + safety: count(facets, (row) => matchesWorkspaceCleanupSafety(row, filters.safety)) + } +} + +function count( + facets: readonly WorkspaceCleanupFacets[], + predicate: (facets: WorkspaceCleanupFacets) => boolean +): number { + let total = 0 + for (const entry of facets) { + if (predicate(entry)) { + total += 1 + } + } + return total +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-relative-time.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-relative-time.ts new file mode 100644 index 00000000000..1391ec7327b --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-relative-time.ts @@ -0,0 +1,27 @@ +import { translate } from '@/i18n/i18n' + +/** Compact "how long ago" label for cleanup rows; 0 means Orca never recorded it. */ +export function formatWorkspaceCleanupRelativeTime(timestamp: number, now = Date.now()): string { + if (!timestamp) { + return translate('components.workspace.cleanup.relativeTime.never', 'Never') + } + const deltaMs = now - timestamp + if (deltaMs < 60_000) { + return translate('components.workspace.cleanup.relativeTime.justNow', 'Just now') + } + const minutes = Math.floor(deltaMs / 60_000) + if (minutes < 60) { + return translate('components.workspace.cleanup.relativeTime.minutesAgo', '{{value0}}m ago', { + value0: minutes + }) + } + const hours = Math.floor(minutes / 60) + if (hours < 48) { + return translate('components.workspace.cleanup.relativeTime.hoursAgo', '{{value0}}h ago', { + value0: hours + }) + } + return translate('components.workspace.cleanup.relativeTime.daysAgo', '{{value0}}d ago', { + value0: Math.floor(hours / 24) + }) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-session.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-session.ts deleted file mode 100644 index e202ac8bfe5..00000000000 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-session.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { - useCallback, - useRef, - useState, - type Dispatch, - type RefObject, - type SetStateAction -} from 'react' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' -import type { AppState } from '@/store/types' -import { useAppStore } from '@/store' -import { - startWorkspaceCleanupBackgroundRemoval, - type WorkspaceCleanupRemovalProgress -} from './workspace-cleanup-background-removal' -import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' - -type WorkspaceCleanupSelectedIdsSetter = Dispatch>> - -export type WorkspaceCleanupRemovalSession = { - confirming: boolean - confirmCandidates: WorkspaceCleanupCandidate[] - removalProgress: WorkspaceCleanupRemovalProgress | null - removalInFlight: boolean - rowFailures: Record - removalInFlightRef: RefObject - clearRowFailures: () => void - resetForOpen: () => void - applyScanDefaults: ( - candidates: readonly WorkspaceCleanupCandidate[], - deletingWorktreeIds: ReadonlySet - ) => void - openConfirmRemove: (candidates: readonly WorkspaceCleanupCandidate[]) => void - cancelConfirmRemove: () => void - backToWorkspaceCleanupList: () => void - confirmRemove: () => void -} - -export function useWorkspaceCleanupRemovalSession({ - mountedRef, - setSelectedIds, - closeModal, - removeCandidates, - markWorktreesQueuedForDeletion, - clearWorktreeDeleteState -}: { - mountedRef: RefObject - setSelectedIds: WorkspaceCleanupSelectedIdsSetter - closeModal: AppState['closeModal'] - removeCandidates: AppState['removeWorkspaceCleanupCandidates'] - markWorktreesQueuedForDeletion: AppState['markWorktreesQueuedForDeletion'] - clearWorktreeDeleteState: AppState['clearWorktreeDeleteState'] -}): WorkspaceCleanupRemovalSession { - const [confirming, setConfirming] = useState(false) - const [confirmCandidates, setConfirmCandidates] = useState([]) - const [removalProgress, setRemovalProgress] = useState( - null - ) - // Why: progress arrives after the click; the ref synchronously rejects a second batch. - const [removalInFlight, setRemovalInFlight] = useState(false) - const [rowFailures, setRowFailures] = useState>({}) - const removalInFlightRef = useRef(false) - // Why: an older batch's late settlement must not mutate a newer batch's UI. - const removalBatchIdRef = useRef(0) - - const clearRowFailures = useCallback(() => setRowFailures({}), []) - - const resetForOpen = useCallback(() => { - setConfirming(false) - setRowFailures({}) - setSelectedIds(new Set()) - }, [setSelectedIds]) - - const applyScanDefaults = useCallback( - ( - candidates: readonly WorkspaceCleanupCandidate[], - deletingWorktreeIds: ReadonlySet - ) => { - if (removalInFlightRef.current) { - return - } - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(candidates, deletingWorktreeIds)) - setConfirming(false) - setRowFailures({}) - }, - [setSelectedIds] - ) - - const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { - const nextCandidates = filterWorkspaceCleanupRemovalCandidates( - candidates, - useAppStore.getState().deleteStateByWorktreeId - ) - if (nextCandidates.length === 0) { - return - } - setConfirmCandidates(nextCandidates) - setConfirming(true) - }, []) - - const cancelConfirmRemove = useCallback(() => { - if (removalProgress) { - closeModal() - return - } - setConfirming(false) - setConfirmCandidates([]) - }, [closeModal, removalProgress]) - - // Why: Back exposes per-row progress without cancelling the batch; Close dismisses the modal. - const backToWorkspaceCleanupList = useCallback(() => { - setConfirming(false) - setConfirmCandidates([]) - }, []) - - const clearQueuedDeleteState = useCallback( - (worktreeId: string) => { - const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId] - // Why: rows that fail before removal starts must not stay queued in the sidebar. - if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') { - clearWorktreeDeleteState(worktreeId) - } - }, - [clearWorktreeDeleteState] - ) - - const deselectRemovedIds = useCallback( - (removedIds: readonly string[]) => { - if (removedIds.length === 0) { - return - } - setSelectedIds((current) => { - const next = new Set(current) - for (const id of removedIds) { - next.delete(id) - } - return next - }) - }, - [setSelectedIds] - ) - - const confirmRemove = useCallback(() => { - if (confirmCandidates.length === 0 || removalInFlightRef.current) { - return - } - const removableCandidates = filterWorkspaceCleanupRemovalCandidates( - confirmCandidates, - useAppStore.getState().deleteStateByWorktreeId - ) - if (removableCandidates.length === 0) { - setConfirming(false) - setConfirmCandidates([]) - return - } - removalInFlightRef.current = true - setRemovalInFlight(true) - removalBatchIdRef.current += 1 - const removalBatchId = removalBatchIdRef.current - // Why: hung late settlements retain callbacks, so capture ids instead of candidate objects. - const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId) - setRowFailures({}) - markWorktreesQueuedForDeletion(removableWorktreeIds) - startWorkspaceCleanupBackgroundRemoval({ - candidates: removableCandidates, - removeCandidates, - onProgress: (progress) => { - if (mountedRef.current) { - setRemovalProgress(progress) - } - }, - onRowFailed: (failure) => { - clearQueuedDeleteState(failure.worktreeId) - }, - onResult: (result) => { - const nextFailures: Record = {} - for (const failure of result.failures) { - nextFailures[failure.worktreeId] = failure.message - clearQueuedDeleteState(failure.worktreeId) - } - if (mountedRef.current) { - setRowFailures(nextFailures) - deselectRemovedIds(result.removedIds) - setRemovalProgress(null) - setRemovalInFlight(false) - setConfirming(false) - setConfirmCandidates([]) - } - removalInFlightRef.current = false - }, - onLateResult: (result) => { - for (const failure of result.failures) { - // Why: a late preflight failure can leave a row that never started stuck as queued. - clearQueuedDeleteState(failure.worktreeId) - } - if (!mountedRef.current || removalBatchIdRef.current !== removalBatchId) { - return - } - setRowFailures((current) => { - const next = { ...current } - for (const id of result.removedIds) { - delete next[id] - } - for (const failure of result.failures) { - next[failure.worktreeId] = failure.message - } - return next - }) - deselectRemovedIds(result.removedIds) - }, - onError: () => { - // Why: the batch driver is gone, so nothing will settle these rows later; clear - // unconditionally (not just queued ones) or a hung 'deleting' row stays stuck in - // the sidebar and filterWorkspaceCleanupRemovalCandidates blocks every retry. - for (const worktreeId of removableWorktreeIds) { - clearWorktreeDeleteState(worktreeId) - } - if (mountedRef.current) { - setRemovalProgress(null) - setRemovalInFlight(false) - setConfirming(false) - setConfirmCandidates([]) - } - removalInFlightRef.current = false - } - }) - }, [ - clearQueuedDeleteState, - clearWorktreeDeleteState, - confirmCandidates, - deselectRemovedIds, - markWorktreesQueuedForDeletion, - mountedRef, - removeCandidates - ]) - - return { - confirming, - confirmCandidates, - removalProgress, - removalInFlight, - rowFailures, - removalInFlightRef, - clearRowFailures, - resetForOpen, - applyScanDefaults, - openConfirmRemove, - cancelConfirmRemove, - backToWorkspaceCleanupList, - confirmRemove - } -} - -function getDefaultSelectedWorkspaceCleanupIds( - candidates: readonly WorkspaceCleanupCandidate[], - deletingWorktreeIds: ReadonlySet -): Set { - return new Set( - candidates - .filter( - (candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId) - ) - .map((candidate) => candidate.worktreeId) - ) -} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx new file mode 100644 index 00000000000..e706c60b486 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx @@ -0,0 +1,98 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { formatBytes } from '../status-bar/workspace-space-format' +import { CandidateRow, type WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' +import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' +import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices' +import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' +import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' + +export type WorkspaceCleanupRowListState = { + rows: readonly WorkspaceCleanupFacets[] + /** The dialog's "as of" clock; keeps row labels consistent with filter bucketing. */ + now: number + /** Rows the scan produced before filtering; separates "empty fleet" from "no matches". */ + scannedCount: number + hasScanned: boolean + loading: boolean + deletionPhaseByWorktreeId: Record + deletingWorktreeIds: ReadonlySet + expandedRowIds: ReadonlySet + selectedIds: ReadonlySet + gitPendingWorktreeIds: ReadonlySet + rowFailures: Record + removalInFlight: boolean + scrollElement: HTMLDivElement | null + onClearFilters: () => void + onToggleExpanded: (worktreeId: string) => void + onToggleSelected: (worktreeId: string) => void + onView: (candidate: WorkspaceCleanupCandidate) => void + onIgnore: (candidate: WorkspaceCleanupCandidate) => void + onRemove: (candidate: WorkspaceCleanupCandidate) => void +} + +export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): React.JSX.Element { + const { rows, loading, hasScanned, scannedCount } = props + const settled = !loading && hasScanned + return ( + <> + {settled && scannedCount === 0 ? ( + + ) : null} + {hasScanned && scannedCount > 0 && rows.length === 0 ? ( + + ) : null} + ( + 1 && index === rows.length - 1} + expanded={props.expandedRowIds.has(row.worktreeId)} + lastActivityLabel={formatWorkspaceCleanupRelativeTime(row.lastActivityAt, props.now)} + sizeLabel={row.sizeBytes === null ? null : formatBytes(row.sizeBytes)} + workspaceStatusLabel={row.workspaceStatusLabel} + gitEvidencePending={props.gitPendingWorktreeIds.has(row.worktreeId)} + deletionPhase={props.deletionPhaseByWorktreeId[row.worktreeId]} + // Why: a background rescan must not lock rows; only an actual + // removal batch or this row's own deletion disables it. + removing={props.removalInFlight || props.deletingWorktreeIds.has(row.worktreeId)} + selected={ + props.selectedIds.has(row.worktreeId) && + !props.deletingWorktreeIds.has(row.worktreeId) + } + failure={props.rowFailures[row.worktreeId]} + onToggleExpanded={props.onToggleExpanded} + onToggleSelected={props.onToggleSelected} + onView={props.onView} + onIgnore={props.onIgnore} + onRemove={props.onRemove} + /> + )} + /> + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts new file mode 100644 index 00000000000..e05d4b2caa7 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts @@ -0,0 +1,121 @@ +import { translate } from '@/i18n/i18n' +import type { + WorkspaceCleanupScanError, + WorkspaceCleanupScanProgress +} from '../../../../shared/workspace-cleanup' + +function isDisconnectedRemoteScanError(message: string): boolean { + return ( + message === 'SSH provider is unavailable.' || + message === 'Remote workspaces are not connected. Reconnect and refresh to check them.' + ) +} + +export function formatWorkspaceCleanupScanNotice( + errors: readonly WorkspaceCleanupScanError[], + repoNameById: ReadonlyMap +): string | null { + const visibleErrors = errors.filter( + (error) => !isDisconnectedRemoteScanError(error.message ?? '') + ) + if (visibleErrors.length === 0) { + return null + } + if (visibleErrors.length === 1) { + const error = visibleErrors[0] + const repoName = formatScanErrorRepoName(error, repoNameById) + return translate( + 'components.workspace.cleanup.scan.singleError', + 'Could not check {{value0}}: {{value1}}. Some workspaces may be missing. Refresh to try again.', + { value0: repoName, value1: formatScanErrorReason(error.message) } + ) + } + const repoNames = visibleErrors + .slice(0, 3) + .map((error) => formatScanErrorRepoName(error, repoNameById)) + .join(', ') + const moreCount = visibleErrors.length - 3 + const suffix = + moreCount > 0 + ? translate('components.workspace.cleanup.scan.moreErrors', ', +{{value0}} more', { + value0: moreCount + }) + : '' + return translate( + 'components.workspace.cleanup.scan.multipleErrors', + 'Could not check {{value0}} repositories ({{value1}}{{value2}}). Some workspaces may be missing. Refresh to try again.', + { value0: visibleErrors.length, value1: repoNames, value2: suffix } + ) +} + +export function formatWorkspaceCleanupScanProgress( + progress: WorkspaceCleanupScanProgress | null +): string { + if (!progress || progress.scannedWorktreeCount === 0) { + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe', + 'Finding workspaces...' + ) + } + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181', + 'Checked workspaces so far: {{value0}}', + { value0: progress.scannedWorktreeCount } + ) +} + +export function formatWorkspaceCleanupReadyToast( + workspaceCount: number, + suggestedCount: number +): string { + if (workspaceCount === 0) { + return translate('components.workspace.cleanup.scan.noWorkspaces', 'No workspaces found.') + } + if (workspaceCount === 1) { + return suggestedCount === 1 + ? translate( + 'components.workspace.cleanup.scan.readyOneOne', + '1 workspace found, with 1 cleanup suggestion.' + ) + : translate( + 'components.workspace.cleanup.scan.readyOneMany', + '1 workspace found, with {{value0}} cleanup suggestions.', + { value0: suggestedCount } + ) + } + return suggestedCount === 1 + ? translate( + 'components.workspace.cleanup.scan.readyManyOne', + '{{value0}} workspaces found, with 1 cleanup suggestion.', + { value0: workspaceCount } + ) + : translate( + 'components.workspace.cleanup.scan.readyManyMany', + '{{value0}} workspaces found, with {{value1}} cleanup suggestions.', + { value0: workspaceCount, value1: suggestedCount } + ) +} + +function formatScanErrorRepoName( + error: Partial, + repoNameById: ReadonlyMap +): string { + const repoName = error.repoName?.trim() + if (repoName) { + return repoName + } + const fallback = error.repoId ? repoNameById.get(error.repoId)?.trim() : '' + return ( + fallback || translate('components.workspace.cleanup.scan.fallbackRepository', 'a repository') + ) +} + +function formatScanErrorReason(message: string | undefined): string { + if (!message || message === 'Could not scan workspace cleanup for this repository.') { + return translate( + 'components.workspace.cleanup.scan.gitListFailed', + 'Git could not list worktrees' + ) + } + return message.replace(/\.$/, '') +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-session.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-session.ts deleted file mode 100644 index fc4cd59b1fd..00000000000 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-session.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useCallback, useEffect, useRef, type RefObject } from 'react' -import { toast } from 'sonner' -import type { AppState } from '@/store/types' -import { translate } from '@/i18n/i18n' - -export function useWorkspaceCleanupScanSession({ - open, - mountedRef, - openModal, - scanWorkspaceCleanup, - clearRowFailures -}: { - open: boolean - mountedRef: RefObject - openModal: AppState['openModal'] - scanWorkspaceCleanup: AppState['scanWorkspaceCleanup'] - clearRowFailures: () => void -}): (options?: { notifyWhenReady?: boolean }) => void { - const openRef = useRef(open) - const latestReadyToastScanAtRef = useRef(null) - - useEffect(() => { - openRef.current = open - }, [open]) - - return useCallback( - (options: { notifyWhenReady?: boolean } = {}) => { - clearRowFailures() - void scanWorkspaceCleanup() - .then((result) => { - if (!mountedRef.current || !options.notifyWhenReady || openRef.current) { - return - } - if (latestReadyToastScanAtRef.current === result.scannedAt) { - return - } - latestReadyToastScanAtRef.current = result.scannedAt - const suggestedCount = result.candidates.filter( - (candidate) => candidate.selectedByDefault - ).length - toast.success( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63', - 'Inactive workspace scan ready' - ), - { - description: formatWorkspaceCleanupReadyToastDescription( - result.candidates.length, - suggestedCount - ), - action: { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764', - 'Review' - ), - onClick: () => openModal('workspace-cleanup') - } - } - ) - }) - .catch((error: unknown) => { - if (!mountedRef.current) { - return - } - toast.error( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.662b8ec3f8', - 'Workspace cleanup scan failed' - ), - { description: error instanceof Error ? error.message : String(error) } - ) - }) - }, - [clearRowFailures, mountedRef, openModal, scanWorkspaceCleanup] - ) -} - -function formatWorkspaceCleanupReadyToastDescription( - inactiveCount: number, - suggestedCount: number -): string { - if (inactiveCount === 0) { - return 'No inactive workspaces found.' - } - const inactiveNoun = inactiveCount === 1 ? 'workspace' : 'workspaces' - const suggestedNoun = suggestedCount === 1 ? 'suggestion' : 'suggestions' - return `${inactiveCount} inactive ${inactiveNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.` -} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts new file mode 100644 index 00000000000..b32e789a5b7 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts @@ -0,0 +1,38 @@ +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' + +export function formatVanishedSelectionNotice(count: number): string { + return count === 1 + ? translate( + 'components.workspace.cleanup.browse.selectionVanishedOne', + '1 selected workspace no longer exists.' + ) + : translate( + 'components.workspace.cleanup.browse.selectionVanished', + '{{value0}} selected workspaces no longer exist.', + { value0: count } + ) +} + +export function getDefaultSelectedWorkspaceCleanupIds( + candidates: readonly WorkspaceCleanupCandidate[], + deletingWorktreeIds: ReadonlySet = new Set() +): Set { + return new Set( + candidates + .filter( + (candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId) + ) + .map((candidate) => candidate.worktreeId) + ) +} + +export function toggleSetMember(current: Set, value: string): Set { + const next = new Set(current) + if (next.has(value)) { + next.delete(value) + } else { + next.add(value) + } + return next +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx new file mode 100644 index 00000000000..0300865b531 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model' +import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header' + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect: () => void }) => ( + + ), + DropdownMenuLabel: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function render(sort: WorkspaceCleanupSortState, handlers: Record = {}): void { + act(() => + root?.render( + + ) + ) +} + +function sortOption(label: string): HTMLButtonElement | undefined { + return [...(container?.querySelectorAll('[data-sort-option]') ?? [])].find( + (button) => button.textContent?.trim() === label + ) +} + +describe('WorkspaceCleanupSortHeader', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('shows the active sort in one menu trigger', () => { + render({ field: 'size', direction: 'desc' }) + + expect(container?.querySelector('[aria-label="Sort by Size"]')?.textContent).toContain( + 'Sort by Size' + ) + }) + + it('routes a menu choice through the sort toggle', () => { + const onToggleSortField = vi.fn() + render({ field: 'last-activity', direction: 'asc' }, { onToggleSortField }) + + act(() => sortOption('Repository')?.click()) + + expect(onToggleSortField).toHaveBeenCalledWith('repo') + }) + + it('select-all reads the query result rather than the rendered page', () => { + const onToggleSelectAll = vi.fn() + render({ field: 'name', direction: 'asc' }, { onToggleSelectAll }) + + const checkbox = container?.querySelector('[role="checkbox"]') + expect(checkbox?.getAttribute('aria-checked')).toBe('false') + act(() => checkbox?.click()) + + expect(onToggleSelectAll).toHaveBeenCalledWith(true) + }) + + it('exposes a partial selection as mixed', () => { + render({ field: 'name', direction: 'asc' }, { selectableCount: 3, selectedCount: 1 }) + + expect( + container?.querySelector('[role="checkbox"]')?.getAttribute('aria-checked') + ).toBe('mixed') + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx new file mode 100644 index 00000000000..2e8de45c1a8 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import { ArrowDown, ArrowUp, Check, ChevronsUpDown, Minus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' +import { WORKSPACE_CLEANUP_SORT_FIELD_VALUES } from '../../../../shared/workspace-cleanup-facet-rankings' +import type { + WorkspaceCleanupSortField, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' +import { getWorkspaceCleanupSortFieldLabel } from './workspace-cleanup-facet-labels' + +export function WorkspaceCleanupSortHeader({ + sort, + selectableCount, + selectedCount, + onToggleSortField, + onToggleSelectAll +}: { + sort: WorkspaceCleanupSortState + selectableCount: number + selectedCount: number + onToggleSortField: (field: WorkspaceCleanupSortField) => void + onToggleSelectAll: (selectAll: boolean) => void +}): React.JSX.Element { + const allSelected = selectableCount > 0 && selectedCount >= selectableCount + const someSelected = selectableCount > 0 && selectedCount > 0 && !allSelected + return ( +
+ + + {translate( + 'components.workspace.cleanup.browse.selectAll', + 'Select all matching workspaces' + )} + + + + + + + + {translate('components.workspace.cleanup.browse.sortBy', 'Sort by')} + + {WORKSPACE_CLEANUP_SORT_FIELD_VALUES.map((field) => ( + onToggleSortField(field)}> + {getWorkspaceCleanupSortFieldLabel(field)} + {sort.field === field ? : null} + + ))} + + +
+ ) +} + +function SortDirectionIcon({ direction }: { direction: 'asc' | 'desc' }): React.JSX.Element { + return direction === 'asc' ? ( +